cherry_pick.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 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 | } |
| 28 | |
| 29 | The schema is identical across all code paths (success, ``--no-commit``, |
| 30 | ``--dry-run``, conflict). |
| 31 | |
| 32 | Exit codes:: |
| 33 | |
| 34 | 0 — success (picked, applied to workdir, or dry-run) |
| 35 | 1 — ref not found, conflict, invalid format, invalid branch name |
| 36 | 3 — internal error (unreadable target snapshot) |
| 37 | """ |
| 38 | |
| 39 | from __future__ import annotations |
| 40 | |
| 41 | import argparse |
| 42 | import datetime |
| 43 | import json |
| 44 | import logging |
| 45 | import sys |
| 46 | |
| 47 | from muse.core.errors import ExitCode |
| 48 | from muse.core.merge_engine import write_merge_state |
| 49 | from muse.core.reflog import append_reflog |
| 50 | from muse.core.repo import read_repo_id, require_repo |
| 51 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id, directories_from_manifest |
| 52 | from muse.core.store import ( |
| 53 | CommitRecord, |
| 54 | SnapshotRecord, |
| 55 | get_head_commit_id, |
| 56 | get_head_snapshot_manifest, |
| 57 | read_commit, |
| 58 | read_current_branch, |
| 59 | read_snapshot, |
| 60 | resolve_commit_ref, |
| 61 | write_branch_ref, |
| 62 | write_commit, |
| 63 | write_snapshot, |
| 64 | ) |
| 65 | from muse.core.validation import sanitize_display, validate_branch_name |
| 66 | from muse.core.workdir import apply_manifest |
| 67 | from muse.cli.guard import require_clean_workdir |
| 68 | from muse.domain import SnapshotManifest |
| 69 | from muse.plugins.registry import read_domain, resolve_plugin |
| 70 | from muse.core._types import Manifest |
| 71 | |
| 72 | logger = logging.getLogger(__name__) |
| 73 | |
| 74 | |
| 75 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 76 | """Register the ``muse cherry-pick`` subcommand and all its flags.""" |
| 77 | parser = subparsers.add_parser( |
| 78 | "cherry-pick", |
| 79 | help="Apply a specific commit's changes on top of HEAD.", |
| 80 | description=__doc__, |
| 81 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 82 | ) |
| 83 | parser.add_argument("ref", help="Commit ID (full or prefix) to apply.") |
| 84 | parser.add_argument( |
| 85 | "-m", "--message", default=None, |
| 86 | help="Override the cherry-pick commit message (default: re-uses the source message).", |
| 87 | ) |
| 88 | parser.add_argument( |
| 89 | "-n", "--no-commit", action="store_true", dest="no_commit", |
| 90 | help="Apply the changes to the working tree without creating a commit.", |
| 91 | ) |
| 92 | parser.add_argument( |
| 93 | "--force", action="store_true", |
| 94 | help="Proceed even if the working tree has uncommitted changes.", |
| 95 | ) |
| 96 | parser.add_argument( |
| 97 | "--dry-run", action="store_true", dest="dry_run", |
| 98 | help=( |
| 99 | "Simulate the cherry-pick without writing anything. " |
| 100 | "Reports what would be applied and whether conflicts would arise." |
| 101 | ), |
| 102 | ) |
| 103 | parser.add_argument( |
| 104 | "--format", "-f", default="text", dest="fmt", |
| 105 | help="Output format: text (default) or json.", |
| 106 | ) |
| 107 | parser.add_argument( |
| 108 | "--json", action="store_const", const="json", dest="fmt", |
| 109 | help="Shorthand for --format json.", |
| 110 | ) |
| 111 | parser.set_defaults(func=run) |
| 112 | |
| 113 | |
| 114 | def run(args: argparse.Namespace) -> None: |
| 115 | """Apply a specific commit's changes on top of HEAD. |
| 116 | |
| 117 | Computes the delta between ``ref`` and its parent, then applies that |
| 118 | delta to the current HEAD snapshot via a three-way merge. |
| 119 | |
| 120 | All error messages are written to **stderr**; stdout is reserved for |
| 121 | structured output only. |
| 122 | |
| 123 | Agents should pass ``--format json`` for a stable, machine-readable |
| 124 | result with a consistent schema across all paths:: |
| 125 | |
| 126 | { |
| 127 | "status": "picked | applied | conflict | dry_run", |
| 128 | "commit_id": "<sha256> | null", |
| 129 | "branch": "<current-branch>", |
| 130 | "ref": "<ref-as-passed>", |
| 131 | "source_commit_id": "<sha256>", |
| 132 | "snapshot_id": "<sha256> | null", |
| 133 | "message": "<commit-message>", |
| 134 | "no_commit": false, |
| 135 | "dry_run": false, |
| 136 | "conflicts": [] |
| 137 | } |
| 138 | |
| 139 | Pass ``--dry-run`` to detect conflicts before committing. The working |
| 140 | tree and branch pointer are never modified. |
| 141 | |
| 142 | Pass ``--no-commit`` to stage the result in the working tree for a |
| 143 | subsequent ``muse commit``. |
| 144 | |
| 145 | Pass ``-m`` / ``--message`` to override the cherry-picked commit message. |
| 146 | |
| 147 | Exit codes:: |
| 148 | |
| 149 | 0 — success (picked, applied to workdir, or dry-run) |
| 150 | 1 — ref not found, conflict, invalid format, invalid branch name |
| 151 | 3 — internal error (unreadable target snapshot) |
| 152 | """ |
| 153 | ref: str = args.ref |
| 154 | message: str | None = args.message |
| 155 | no_commit: bool = args.no_commit |
| 156 | force: bool = args.force |
| 157 | dry_run: bool = getattr(args, "dry_run", False) |
| 158 | fmt: str = args.fmt |
| 159 | |
| 160 | if fmt not in ("text", "json"): |
| 161 | print( |
| 162 | f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", |
| 163 | file=sys.stderr, |
| 164 | ) |
| 165 | raise SystemExit(ExitCode.USER_ERROR) |
| 166 | |
| 167 | root = require_repo() |
| 168 | # Dry-run never touches the working tree. |
| 169 | if not dry_run: |
| 170 | require_clean_workdir(root, "cherry-pick", force=force) |
| 171 | repo_id = read_repo_id(root) |
| 172 | branch = read_current_branch(root) |
| 173 | |
| 174 | try: |
| 175 | validate_branch_name(branch) |
| 176 | except ValueError as exc: |
| 177 | print( |
| 178 | f"❌ Current branch name is invalid: {sanitize_display(str(exc))}", |
| 179 | file=sys.stderr, |
| 180 | ) |
| 181 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 182 | |
| 183 | domain = read_domain(root) |
| 184 | plugin = resolve_plugin(root) |
| 185 | |
| 186 | target = resolve_commit_ref(root, repo_id, branch, ref) |
| 187 | if target is None: |
| 188 | print( |
| 189 | f"❌ Commit '{sanitize_display(ref)}' not found.", |
| 190 | file=sys.stderr, |
| 191 | ) |
| 192 | raise SystemExit(ExitCode.USER_ERROR) |
| 193 | |
| 194 | # Validate the target snapshot before touching the working tree. |
| 195 | target_snap_rec = read_snapshot(root, target.snapshot_id) |
| 196 | if target_snap_rec is None: |
| 197 | print( |
| 198 | f"❌ Snapshot {target.snapshot_id[:8]} for commit {target.commit_id[:8]} not found.", |
| 199 | file=sys.stderr, |
| 200 | ) |
| 201 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 202 | target_manifest = target_snap_rec.manifest |
| 203 | |
| 204 | # Build the base manifest (the parent of the cherry-picked commit). |
| 205 | # Fail fast if the parent commit is recorded but its data is missing — |
| 206 | # that indicates object-store corruption, not a legitimate root commit. |
| 207 | base_manifest: Manifest = {} |
| 208 | if target.parent_commit_id: |
| 209 | parent_commit = read_commit(root, target.parent_commit_id) |
| 210 | if parent_commit is None: |
| 211 | print( |
| 212 | f"❌ Parent commit {target.parent_commit_id[:8]} not found — " |
| 213 | "object store may be corrupted.", |
| 214 | file=sys.stderr, |
| 215 | ) |
| 216 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 217 | parent_snap = read_snapshot(root, parent_commit.snapshot_id) |
| 218 | if parent_snap is None: |
| 219 | print( |
| 220 | f"❌ Parent snapshot {parent_commit.snapshot_id[:8]} not found — " |
| 221 | "object store may be corrupted.", |
| 222 | file=sys.stderr, |
| 223 | ) |
| 224 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 225 | base_manifest = parent_snap.manifest |
| 226 | |
| 227 | ours_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {} |
| 228 | |
| 229 | base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest)) |
| 230 | ours_snap = SnapshotManifest(files=ours_manifest, domain=domain, directories=directories_from_manifest(ours_manifest)) |
| 231 | target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest)) |
| 232 | |
| 233 | result = plugin.merge(base_snap, ours_snap, target_snap) |
| 234 | |
| 235 | # Sanitize the source commit message before embedding it in any stored commit. |
| 236 | safe_message = sanitize_display(target.message.splitlines()[0]) |
| 237 | commit_message = message or safe_message |
| 238 | |
| 239 | if not result.is_clean: |
| 240 | # Write merge state so `muse conflicts` and `muse checkout --ours/--theirs` |
| 241 | # can inspect the conflict without re-running cherry-pick. |
| 242 | write_merge_state( |
| 243 | root, |
| 244 | base_commit=target.parent_commit_id or "", |
| 245 | ours_commit=get_head_commit_id(root, branch) or "", |
| 246 | theirs_commit=target.commit_id, |
| 247 | conflict_paths=result.conflicts, |
| 248 | ) |
| 249 | if fmt == "json": |
| 250 | print(json.dumps({ |
| 251 | "status": "conflict", |
| 252 | "commit_id": None, |
| 253 | "branch": branch, |
| 254 | "ref": ref, |
| 255 | "source_commit_id": target.commit_id, |
| 256 | "snapshot_id": None, |
| 257 | "message": commit_message, |
| 258 | "no_commit": no_commit, |
| 259 | "dry_run": False, |
| 260 | "conflicts": sorted(result.conflicts), |
| 261 | })) |
| 262 | else: |
| 263 | print( |
| 264 | f"❌ Cherry-pick conflict in {len(result.conflicts)} file(s):", |
| 265 | file=sys.stderr, |
| 266 | ) |
| 267 | for p in sorted(result.conflicts): |
| 268 | print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr) |
| 269 | raise SystemExit(ExitCode.USER_ERROR) |
| 270 | |
| 271 | merged_manifest = result.merged["files"] |
| 272 | |
| 273 | # Dry-run: all validation passed — report and exit without writes. |
| 274 | if dry_run: |
| 275 | snapshot_id = compute_snapshot_id(merged_manifest, directories_from_manifest(merged_manifest)) |
| 276 | if fmt == "json": |
| 277 | print(json.dumps({ |
| 278 | "status": "dry_run", |
| 279 | "commit_id": None, |
| 280 | "branch": branch, |
| 281 | "ref": ref, |
| 282 | "source_commit_id": target.commit_id, |
| 283 | "snapshot_id": snapshot_id, |
| 284 | "message": commit_message, |
| 285 | "no_commit": no_commit, |
| 286 | "dry_run": True, |
| 287 | "conflicts": [], |
| 288 | })) |
| 289 | else: |
| 290 | print( |
| 291 | f"[dry-run] Would cherry-pick '{sanitize_display(ref)}' " |
| 292 | f"({target.commit_id[:8]}) on '{sanitize_display(branch)}'" |
| 293 | ) |
| 294 | return |
| 295 | |
| 296 | if no_commit: |
| 297 | apply_manifest(root, merged_manifest) |
| 298 | if fmt == "json": |
| 299 | print(json.dumps({ |
| 300 | "status": "applied", |
| 301 | "commit_id": None, |
| 302 | "branch": branch, |
| 303 | "ref": ref, |
| 304 | "source_commit_id": target.commit_id, |
| 305 | "snapshot_id": None, |
| 306 | "message": commit_message, |
| 307 | "no_commit": True, |
| 308 | "dry_run": False, |
| 309 | "conflicts": [], |
| 310 | })) |
| 311 | else: |
| 312 | print( |
| 313 | f"Applied {target.commit_id[:8]} to working tree. " |
| 314 | f"Run 'muse commit' to record." |
| 315 | ) |
| 316 | return |
| 317 | |
| 318 | # Correct write ordering for atomicity: |
| 319 | # 1. Compute snapshot_id and commit_id (pure computation — no I/O). |
| 320 | # 2. write_snapshot (idempotent — crash here leaves workdir unchanged). |
| 321 | # 3. write_commit (idempotent — crash here leaves workdir unchanged). |
| 322 | # 4. apply_manifest (workdir modified only after both objects are durable). |
| 323 | # 5. write_branch_ref (branch pointer advances last — visible to others only when complete). |
| 324 | # 6. append_reflog (non-critical audit trail — never blocks success). |
| 325 | head_commit_id = get_head_commit_id(root, branch) |
| 326 | manifest = merged_manifest |
| 327 | manifest_dirs = directories_from_manifest(manifest) |
| 328 | snapshot_id = compute_snapshot_id(manifest, manifest_dirs) |
| 329 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 330 | commit_id = compute_commit_id( |
| 331 | parent_ids=[head_commit_id] if head_commit_id else [], |
| 332 | snapshot_id=snapshot_id, |
| 333 | message=commit_message, |
| 334 | committed_at_iso=committed_at.isoformat(), |
| 335 | ) |
| 336 | |
| 337 | write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=manifest_dirs)) |
| 338 | write_commit(root, CommitRecord( |
| 339 | commit_id=commit_id, |
| 340 | repo_id=repo_id, |
| 341 | branch=branch, |
| 342 | snapshot_id=snapshot_id, |
| 343 | message=commit_message, |
| 344 | committed_at=committed_at, |
| 345 | parent_commit_id=head_commit_id, |
| 346 | )) |
| 347 | apply_manifest(root, manifest) |
| 348 | write_branch_ref(root, branch, commit_id) |
| 349 | append_reflog( |
| 350 | root, branch, old_id=head_commit_id, new_id=commit_id, |
| 351 | author="user", |
| 352 | operation=f"cherry-pick: {sanitize_display(ref)} -> {commit_id[:12]}", |
| 353 | ) |
| 354 | |
| 355 | if fmt == "json": |
| 356 | print(json.dumps({ |
| 357 | "status": "picked", |
| 358 | "commit_id": commit_id, |
| 359 | "branch": branch, |
| 360 | "ref": ref, |
| 361 | "source_commit_id": target.commit_id, |
| 362 | "snapshot_id": snapshot_id, |
| 363 | "message": commit_message, |
| 364 | "no_commit": False, |
| 365 | "dry_run": False, |
| 366 | "conflicts": [], |
| 367 | })) |
| 368 | else: |
| 369 | print( |
| 370 | f"[{sanitize_display(branch)} {commit_id[:8]}] " |
| 371 | f"{sanitize_display(commit_message)}" |
| 372 | ) |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago