cherry_pick.py python
372 lines 13.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 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 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago