cherry_pick.py
python
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd
feat: add muse reflog expire subcommand and reflog.expire-d…
Sonnet 4.6
patch
71 days ago
| 1 | """``muse cherry-pick`` — apply a specific commit's changes on top of HEAD. |
| 2 | |
| 3 | Cherry-pick computes the *delta* introduced by a commit (its snapshot vs its |
| 4 | parent's snapshot), then applies that delta on top of the current HEAD via a |
| 5 | three-way merge. The result is a new commit that replays the same change in a |
| 6 | different context. |
| 7 | |
| 8 | Usage:: |
| 9 | |
| 10 | muse cherry-pick <ref> — apply and commit immediately |
| 11 | muse cherry-pick <ref> --no-commit — apply to working tree only |
| 12 | muse cherry-pick <ref> --dry-run — simulate without writing anything |
| 13 | |
| 14 | JSON output (``--format json`` or ``--json``):: |
| 15 | |
| 16 | { |
| 17 | "status": "picked | applied | conflict | dry_run", |
| 18 | "commit_id": "<sha256> | null", |
| 19 | "branch": "<current-branch>", |
| 20 | "ref": "<ref-as-passed>", |
| 21 | "source_commit_id": "<sha256>", |
| 22 | "snapshot_id": "<sha256> | null", |
| 23 | "message": "<commit-message>", |
| 24 | "no_commit": false, |
| 25 | "dry_run": false, |
| 26 | "conflicts": [], |
| 27 | "duration_ms": 0.000123, |
| 28 | "exit_code": 0 |
| 29 | } |
| 30 | |
| 31 | ``duration_ms`` |
| 32 | Wall-clock time from argument parsing to output. |
| 33 | ``exit_code`` |
| 34 | Mirrors the process exit code: ``0`` for success (picked, applied, |
| 35 | dry_run) and ``1`` for conflicts. Lets agents evaluate the result |
| 36 | without inspecting the process exit code separately. |
| 37 | |
| 38 | The schema is identical across all code paths (success, ``--no-commit``, |
| 39 | ``--dry-run``, conflict). |
| 40 | |
| 41 | Exit codes:: |
| 42 | |
| 43 | 0 — success (picked, applied to workdir, or dry-run) |
| 44 | 1 — ref not found, conflict, invalid format, invalid branch name |
| 45 | 3 — internal error (unreadable target snapshot) |
| 46 | """ |
| 47 | |
| 48 | import argparse |
| 49 | import datetime |
| 50 | import json |
| 51 | import logging |
| 52 | import sys |
| 53 | import time |
| 54 | |
| 55 | from muse.core.errors import ExitCode |
| 56 | from muse.core.merge_engine import write_merge_state |
| 57 | from muse.core.reflog import append_reflog |
| 58 | from muse.core.repo import require_repo |
| 59 | from muse.core.ids import hash_commit, hash_snapshot |
| 60 | from muse.core.snapshot import directories_from_manifest |
| 61 | from muse.core.refs import ( |
| 62 | RefConflictError, |
| 63 | get_head_commit_id, |
| 64 | read_current_branch, |
| 65 | write_branch_ref, |
| 66 | ) |
| 67 | from muse.core.commits import ( |
| 68 | CommitRecord, |
| 69 | read_commit, |
| 70 | resolve_commit_ref, |
| 71 | write_commit, |
| 72 | ) |
| 73 | from muse.core.snapshots import ( |
| 74 | SnapshotRecord, |
| 75 | get_head_snapshot_manifest, |
| 76 | read_snapshot, |
| 77 | write_snapshot, |
| 78 | ) |
| 79 | from muse.core.validation import sanitize_display, validate_branch_name |
| 80 | from muse.core.workdir import apply_manifest |
| 81 | from muse.cli.guard import require_clean_workdir |
| 82 | from muse.domain import SnapshotManifest |
| 83 | from muse.plugins.registry import read_domain, resolve_plugin |
| 84 | from muse.core.types import Manifest, short_id |
| 85 | from muse.core.timing import start_timer |
| 86 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 87 | from typing import TypedDict |
| 88 | |
| 89 | logger = logging.getLogger(__name__) |
| 90 | |
| 91 | class _CherryPickErrorJson(EnvelopeJson): |
| 92 | """JSON output for cherry-pick error paths.""" |
| 93 | |
| 94 | error: str |
| 95 | message: str |
| 96 | |
| 97 | class _CherryPickJson(EnvelopeJson): |
| 98 | """JSON output for ``muse cherry-pick --json`` success/conflict paths.""" |
| 99 | |
| 100 | status: str # "conflict" | "dry_run" | "applied" | "picked" |
| 101 | commit_id: str | None |
| 102 | branch: str |
| 103 | ref: str |
| 104 | source_commit_id: str |
| 105 | snapshot_id: str | None |
| 106 | message: str |
| 107 | no_commit: bool |
| 108 | dry_run: bool |
| 109 | conflicts: list[str] |
| 110 | |
| 111 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 112 | """Register the ``muse cherry-pick`` subcommand and all its flags.""" |
| 113 | parser = subparsers.add_parser( |
| 114 | "cherry-pick", |
| 115 | help="Apply a specific commit's changes on top of HEAD.", |
| 116 | description=__doc__, |
| 117 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 118 | ) |
| 119 | parser.add_argument("ref", help="Commit ID (full or prefix) to apply.") |
| 120 | parser.add_argument( |
| 121 | "-m", "--message", default=None, |
| 122 | help="Override the cherry-pick commit message (default: re-uses the source message).", |
| 123 | ) |
| 124 | parser.add_argument( |
| 125 | "--no-commit", action="store_true", dest="no_commit", |
| 126 | help="Apply the changes to the working tree without creating a commit.", |
| 127 | ) |
| 128 | parser.add_argument( |
| 129 | "--force", action="store_true", |
| 130 | help="Proceed even if the working tree has uncommitted changes.", |
| 131 | ) |
| 132 | parser.add_argument( |
| 133 | "--dry-run", action="store_true", dest="dry_run", |
| 134 | help=( |
| 135 | "Simulate the cherry-pick without writing anything. " |
| 136 | "Reports what would be applied and whether conflicts would arise." |
| 137 | ), |
| 138 | ) |
| 139 | parser.add_argument( |
| 140 | "--json", "-j", action="store_true", dest="json_out", |
| 141 | help="Emit machine-readable JSON instead of human text.", |
| 142 | ) |
| 143 | parser.set_defaults(func=run) |
| 144 | |
| 145 | def run(args: argparse.Namespace) -> None: |
| 146 | """Apply a specific commit's changes on top of HEAD. |
| 147 | |
| 148 | Computes the delta between ``ref`` and its parent, applies that delta to |
| 149 | the current HEAD snapshot via a three-way merge. Use ``--dry-run`` to |
| 150 | detect conflicts without modifying the working tree. Use ``--no-commit`` |
| 151 | to stage the result for a subsequent ``muse commit``. |
| 152 | |
| 153 | Agent quickstart |
| 154 | ---------------- |
| 155 | :: |
| 156 | |
| 157 | muse cherry-pick abc123 --json |
| 158 | muse cherry-pick abc123 --dry-run --json |
| 159 | muse cherry-pick abc123 --no-commit --json |
| 160 | |
| 161 | JSON fields |
| 162 | ----------- |
| 163 | status Outcome: ``"picked"``, ``"applied"`` (no-commit), |
| 164 | ``"conflict"``, or ``"dry_run"``. |
| 165 | commit_id New commit ID; ``null`` with ``--no-commit`` or conflicts. |
| 166 | branch Current branch name. |
| 167 | ref Ref as passed on the command line. |
| 168 | source_commit_id The commit that was cherry-picked. |
| 169 | snapshot_id New snapshot ID; ``null`` on conflict or no-commit. |
| 170 | message Commit message used (may differ from source if ``-m`` given). |
| 171 | no_commit ``true`` when ``--no-commit`` was passed. |
| 172 | dry_run ``true`` when ``--dry-run`` was passed. |
| 173 | conflicts List of conflicting file paths (empty on success). |
| 174 | |
| 175 | Exit codes |
| 176 | ---------- |
| 177 | 0 Success (picked, applied to workdir, or dry-run clean). |
| 178 | 1 Ref not found, conflict, or invalid arguments. |
| 179 | 3 Internal error (unreadable target snapshot). |
| 180 | """ |
| 181 | elapsed = start_timer() |
| 182 | ref: str = args.ref |
| 183 | message: str | None = args.message |
| 184 | no_commit: bool = args.no_commit |
| 185 | force: bool = args.force |
| 186 | dry_run: bool = getattr(args, "dry_run", False) |
| 187 | json_out: bool = args.json_out |
| 188 | |
| 189 | root = require_repo() |
| 190 | # Dry-run never touches the working tree. |
| 191 | if not dry_run: |
| 192 | require_clean_workdir(root, "cherry-pick", force=force, json_out=json_out) |
| 193 | branch = read_current_branch(root) |
| 194 | |
| 195 | try: |
| 196 | validate_branch_name(branch) |
| 197 | except ValueError as exc: |
| 198 | print( |
| 199 | f"❌ Current branch name is invalid: {sanitize_display(str(exc))}", |
| 200 | file=sys.stderr, |
| 201 | ) |
| 202 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 203 | |
| 204 | domain = read_domain(root) |
| 205 | plugin = resolve_plugin(root) |
| 206 | |
| 207 | target = resolve_commit_ref(root, branch, ref) |
| 208 | if target is None: |
| 209 | if json_out: |
| 210 | print(json.dumps({"error": "commit_not_found", "ref": ref, "message": f"commit '{ref}' not found"})) |
| 211 | print( |
| 212 | f"❌ Commit '{sanitize_display(ref)}' not found.", |
| 213 | file=sys.stderr, |
| 214 | ) |
| 215 | raise SystemExit(ExitCode.USER_ERROR) |
| 216 | |
| 217 | # Validate the target snapshot before touching the working tree. |
| 218 | target_snap_rec = read_snapshot(root, target.snapshot_id) |
| 219 | if target_snap_rec is None: |
| 220 | if json_out: |
| 221 | print(json.dumps({"error": "snapshot_missing", "snapshot_id": target.snapshot_id, "commit_id": target.commit_id, "message": f"snapshot {target.snapshot_id} for commit {target.commit_id} not found"})) |
| 222 | print( |
| 223 | f"❌ Snapshot {target.snapshot_id} for commit {target.commit_id} not found.", |
| 224 | file=sys.stderr, |
| 225 | ) |
| 226 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 227 | target_manifest = target_snap_rec.manifest |
| 228 | |
| 229 | # Build the base manifest (the parent of the cherry-picked commit). |
| 230 | # Fail fast if the parent commit is recorded but its data is missing — |
| 231 | # that indicates object-store corruption, not a legitimate root commit. |
| 232 | base_manifest: Manifest = {} |
| 233 | if target.parent_commit_id: |
| 234 | parent_commit = read_commit(root, target.parent_commit_id) |
| 235 | if parent_commit is None: |
| 236 | if json_out: |
| 237 | print(json.dumps({"error": "corrupt_store", "commit_id": target.parent_commit_id, "message": f"parent commit {target.parent_commit_id} not found — object store may be corrupted"})) |
| 238 | print( |
| 239 | f"❌ Parent commit {target.parent_commit_id} not found — " |
| 240 | "object store may be corrupted.", |
| 241 | file=sys.stderr, |
| 242 | ) |
| 243 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 244 | parent_snap = read_snapshot(root, parent_commit.snapshot_id) |
| 245 | if parent_snap is None: |
| 246 | if json_out: |
| 247 | print(json.dumps({"error": "corrupt_store", "snapshot_id": parent_commit.snapshot_id, "message": f"parent snapshot {parent_commit.snapshot_id} not found — object store may be corrupted"})) |
| 248 | print( |
| 249 | f"❌ Parent snapshot {parent_commit.snapshot_id} not found — " |
| 250 | "object store may be corrupted.", |
| 251 | file=sys.stderr, |
| 252 | ) |
| 253 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 254 | base_manifest = parent_snap.manifest |
| 255 | |
| 256 | ours_manifest = get_head_snapshot_manifest(root, branch) or {} |
| 257 | |
| 258 | base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest)) |
| 259 | ours_snap = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest)) |
| 260 | target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest)) |
| 261 | |
| 262 | result = plugin.merge(base_snap, ours_snap, target_snap) |
| 263 | |
| 264 | # Sanitize the source commit message before embedding it in any stored commit. |
| 265 | safe_message = sanitize_display(target.message.splitlines()[0]) |
| 266 | commit_message = message or safe_message |
| 267 | |
| 268 | if not result.is_clean: |
| 269 | # Write merge state so `muse conflicts` and `muse checkout --ours/--theirs` |
| 270 | # can inspect the conflict without re-running cherry-pick. |
| 271 | write_merge_state( |
| 272 | root, |
| 273 | base_commit=target.parent_commit_id or "", |
| 274 | ours_commit=get_head_commit_id(root, branch) or "", |
| 275 | theirs_commit=target.commit_id, |
| 276 | conflict_paths=result.conflicts, |
| 277 | ) |
| 278 | if json_out: |
| 279 | print(json.dumps(_CherryPickJson( |
| 280 | **make_envelope(elapsed, exit_code=1), |
| 281 | status="conflict", |
| 282 | commit_id=None, |
| 283 | branch=branch, |
| 284 | ref=ref, |
| 285 | source_commit_id=target.commit_id, |
| 286 | snapshot_id=None, |
| 287 | message=commit_message, |
| 288 | no_commit=no_commit, |
| 289 | dry_run=False, |
| 290 | conflicts=sorted(result.conflicts), |
| 291 | ))) |
| 292 | else: |
| 293 | print( |
| 294 | f"❌ Cherry-pick conflict in {len(result.conflicts)} file(s):", |
| 295 | file=sys.stderr, |
| 296 | ) |
| 297 | for p in sorted(result.conflicts): |
| 298 | print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr) |
| 299 | raise SystemExit(ExitCode.USER_ERROR) |
| 300 | |
| 301 | merged_manifest = result.merged["files"] |
| 302 | |
| 303 | # Dry-run: all validation passed — report and exit without writes. |
| 304 | if dry_run: |
| 305 | snapshot_id = hash_snapshot(merged_manifest, directories_from_manifest(merged_manifest)) |
| 306 | if json_out: |
| 307 | print(json.dumps(_CherryPickJson( |
| 308 | **make_envelope(elapsed), |
| 309 | status="dry_run", |
| 310 | commit_id=None, |
| 311 | branch=branch, |
| 312 | ref=ref, |
| 313 | source_commit_id=target.commit_id, |
| 314 | snapshot_id=snapshot_id, |
| 315 | message=commit_message, |
| 316 | no_commit=no_commit, |
| 317 | dry_run=True, |
| 318 | conflicts=[], |
| 319 | ))) |
| 320 | else: |
| 321 | print( |
| 322 | f"[dry-run] Would cherry-pick '{sanitize_display(ref)}' " |
| 323 | f"({target.commit_id}) on '{sanitize_display(branch)}'" |
| 324 | ) |
| 325 | return |
| 326 | |
| 327 | if no_commit: |
| 328 | apply_manifest(root, ours_manifest, merged_manifest) |
| 329 | if json_out: |
| 330 | print(json.dumps(_CherryPickJson( |
| 331 | **make_envelope(elapsed), |
| 332 | status="applied", |
| 333 | commit_id=None, |
| 334 | branch=branch, |
| 335 | ref=ref, |
| 336 | source_commit_id=target.commit_id, |
| 337 | snapshot_id=None, |
| 338 | message=commit_message, |
| 339 | no_commit=True, |
| 340 | dry_run=False, |
| 341 | conflicts=[], |
| 342 | ))) |
| 343 | else: |
| 344 | print( |
| 345 | f"Applied {target.commit_id} to working tree. " |
| 346 | f"Run 'muse commit' to record." |
| 347 | ) |
| 348 | return |
| 349 | |
| 350 | # Correct write ordering for atomicity: |
| 351 | # 1. Compute snapshot_id and commit_id (pure computation — no I/O). |
| 352 | # 2. write_snapshot (idempotent — crash here leaves workdir unchanged). |
| 353 | # 3. write_commit (idempotent — crash here leaves workdir unchanged). |
| 354 | # 4. apply_manifest (workdir modified only after both objects are durable). |
| 355 | # 5. write_branch_ref (branch pointer advances last — visible to others only when complete). |
| 356 | # 6. append_reflog (non-critical audit trail — never blocks success). |
| 357 | head_commit_id = get_head_commit_id(root, branch) |
| 358 | manifest = merged_manifest |
| 359 | manifest_dirs = directories_from_manifest(manifest) |
| 360 | snapshot_id = hash_snapshot(manifest, manifest_dirs) |
| 361 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 362 | cherry_author = target.author or "" |
| 363 | commit_id = hash_commit( |
| 364 | parent_ids=[head_commit_id] if head_commit_id else [], |
| 365 | snapshot_id=snapshot_id, |
| 366 | message=commit_message, |
| 367 | committed_at_iso=committed_at.isoformat(), |
| 368 | author=cherry_author, |
| 369 | ) |
| 370 | |
| 371 | write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=manifest_dirs)) |
| 372 | write_commit(root, CommitRecord( |
| 373 | commit_id=commit_id, |
| 374 | branch=branch, |
| 375 | snapshot_id=snapshot_id, |
| 376 | message=commit_message, |
| 377 | committed_at=committed_at, |
| 378 | parent_commit_id=head_commit_id, |
| 379 | author=cherry_author, |
| 380 | )) |
| 381 | apply_manifest(root, ours_manifest, manifest) |
| 382 | try: |
| 383 | write_branch_ref(root, branch, commit_id, expected_id=head_commit_id) |
| 384 | except RefConflictError as exc: |
| 385 | print(f"❌ {exc}", file=sys.stderr) |
| 386 | raise SystemExit(ExitCode.USER_ERROR) |
| 387 | append_reflog( |
| 388 | root, branch, old_id=head_commit_id, new_id=commit_id, |
| 389 | author="user", |
| 390 | operation=f"cherry-pick: {sanitize_display(ref)} -> {commit_id}", |
| 391 | ) |
| 392 | |
| 393 | if json_out: |
| 394 | print(json.dumps(_CherryPickJson( |
| 395 | **make_envelope(elapsed), |
| 396 | status="picked", |
| 397 | commit_id=commit_id, |
| 398 | branch=branch, |
| 399 | ref=ref, |
| 400 | source_commit_id=target.commit_id, |
| 401 | snapshot_id=snapshot_id, |
| 402 | message=commit_message, |
| 403 | no_commit=False, |
| 404 | dry_run=False, |
| 405 | conflicts=[], |
| 406 | ))) |
| 407 | else: |
| 408 | print( |
| 409 | f"[{sanitize_display(branch)} {short_id(commit_id)}] " |
| 410 | f"{sanitize_display(commit_message)}" |
| 411 | ) |
File History
1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd
feat: add muse reflog expire subcommand and reflog.expire-d…
Sonnet 4.6
patch
71 days ago