gabriel / muse public
cherry_pick.py python
411 lines 15.4 KB
Raw
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