gabriel / muse public
apply_patch.py python
307 lines 10.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """``muse apply-patch <file.mpatch>`` — apply a Muse patch to the working tree.
2
3 Applies a ``.mpatch`` file produced by ``muse format-patch``. Before touching
4 any file on disk, the command:
5
6 1. Verifies the ``patch_id`` — recomputes the SHA-256 over the canonical JSON
7 and rejects the patch if the IDs don't match.
8 2. Checks applicability — the ``requires_snapshot`` field must match the
9 current HEAD snapshot ID (bypassed with ``--force``).
10 3. Restores all added and modified files from the repo's object store.
11 4. Deletes files listed in ``files_deleted``.
12
13 The result is a dirty working tree ready for ``muse commit``.
14
15 Flags
16 -----
17 ``--dry-run``
18 Report what would change without writing anything to disk.
19
20 ``--check``
21 Report applicability only (does not apply the patch).
22
23 ``--force``
24 Bypass the ``requires_snapshot`` applicability check. Use when you know
25 the patch applies cleanly despite a snapshot mismatch.
26
27 ``--json``
28 Emit a JSON result object to stdout.
29
30 Output (JSON, ``--json``)::
31
32 {
33 "patch_id": "sha256:<64hex>",
34 "files_applied": ["hello.py"],
35 "files_deleted": [],
36 "dry_run": false,
37 "applicable": true,
38 "duration_ms": 0.003412,
39 "exit_code": 0
40 }
41
42 Exit codes::
43
44 0 — success
45 1 — user error: bad patch file, patch_id mismatch, not applicable
46 2 — not a Muse repository
47 3 — I/O error reading patch or restoring files
48
49 Examples::
50
51 muse apply-patch /tmp/patches/feat-add-hello.mpatch
52 muse apply-patch patch.mpatch --dry-run
53 muse apply-patch patch.mpatch --check --json
54 muse apply-patch patch.mpatch --force --json
55 """
56
57 from __future__ import annotations
58
59 import argparse
60 import base64
61 import json as _json
62 import logging
63 import pathlib
64 import sys
65
66 from muse.core._types import long_id, short_id
67 from muse.core.errors import ExitCode
68 from muse.core.object_store import restore_object, write_object
69 from muse.core.patch_record import PatchRecord, compute_patch_id, deserialize_patch
70 from muse.core.repo import read_repo_id, require_repo
71 from muse.core.store import (
72 get_head_snapshot_id,
73 read_current_branch,
74 )
75 from muse.core.validation import sanitize_display
76 from muse.core.envelope import make_envelope
77 from muse.core.timing import start_timer
78
79 logger = logging.getLogger(__name__)
80
81
82 # ---------------------------------------------------------------------------
83 # Registration
84 # ---------------------------------------------------------------------------
85
86
87 def register(
88 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
89 ) -> None:
90 """Register the ``muse apply-patch`` subcommand."""
91 parser = subparsers.add_parser(
92 "apply-patch",
93 help="Apply a Muse .mpatch file to the working tree.",
94 description=__doc__,
95 formatter_class=argparse.RawDescriptionHelpFormatter,
96 )
97 parser.add_argument(
98 "patch_file",
99 metavar="FILE",
100 help="Path to the .mpatch file to apply.",
101 )
102 parser.add_argument(
103 "--dry-run",
104 action="store_true",
105 dest="dry_run",
106 help="Report what would change without touching disk.",
107 )
108 parser.add_argument(
109 "--check",
110 action="store_true",
111 dest="check_only",
112 help="Report applicability only; do not apply the patch.",
113 )
114 parser.add_argument(
115 "--force",
116 action="store_true",
117 dest="force",
118 help="Bypass the requires_snapshot applicability check.",
119 )
120 parser.add_argument(
121 "--json",
122 action="store_true",
123 dest="output_json",
124 help="Emit a JSON result object to stdout.",
125 )
126 parser.set_defaults(func=run)
127
128
129 # ---------------------------------------------------------------------------
130 # Run
131 # ---------------------------------------------------------------------------
132
133
134 def run(args: argparse.Namespace) -> None:
135 """Apply a Muse ``.mpatch`` file to the working tree.
136
137 Verifies patch integrity (``patch_id`` SHA-256), checks applicability
138 against the current HEAD snapshot, restores added/modified files from the
139 object store, and deletes files listed as removed. The result is a dirty
140 working tree ready for ``muse commit``.
141
142 Agent quickstart
143 ----------------
144 ::
145
146 muse apply-patch patch.mpatch --json
147 muse apply-patch patch.mpatch --dry-run --json # preview only
148 muse apply-patch patch.mpatch --check --json # applicability only
149 muse apply-patch patch.mpatch --force --json # skip snapshot check
150
151 JSON fields
152 -----------
153 patch_id SHA-256 patch identifier (``sha256:<hex>``).
154 files_applied List of file paths restored or created.
155 files_deleted List of file paths removed.
156 dry_run ``true`` when ``--dry-run`` was passed (no writes occurred).
157 applicable ``true`` if the current snapshot matches ``requires_snapshot``.
158
159 With ``--check``, only ``patch_id``, ``applicable``, and
160 ``requires_snapshot`` are returned.
161
162 Exit codes
163 ----------
164 0 Patch applied successfully (or dry-run / check passed).
165 1 Patch integrity failure, not applicable, or missing file.
166 2 Not inside a Muse repository.
167 3 I/O error restoring or deleting files.
168 """
169 elapsed = start_timer()
170 patch_path = pathlib.Path(args.patch_file)
171 dry_run: bool = args.dry_run
172 check_only: bool = args.check_only
173 force: bool = args.force
174 output_json: bool = args.output_json
175
176 # ── Load patch file ───────────────────────────────────────────────────────
177 if not patch_path.exists():
178 print(
179 f"❌ Patch file not found: {sanitize_display(str(patch_path))}",
180 file=sys.stderr,
181 )
182 raise SystemExit(ExitCode.USER_ERROR)
183
184 try:
185 raw = patch_path.read_bytes()
186 except OSError as exc:
187 print(f"❌ Could not read patch file: {exc}", file=sys.stderr)
188 raise SystemExit(ExitCode.IO_ERROR)
189
190 try:
191 record: PatchRecord = deserialize_patch(raw)
192 except Exception as exc:
193 print(f"❌ Invalid patch file: {exc}", file=sys.stderr)
194 raise SystemExit(ExitCode.USER_ERROR)
195
196 # ── Verify patch_id integrity ─────────────────────────────────────────────
197 expected_id = compute_patch_id(record)
198 if record.patch_id != expected_id:
199 print(
200 f"❌ Patch integrity check failed.\n"
201 f" Stored: {sanitize_display(record.patch_id)}\n"
202 f" Expected: {sanitize_display(expected_id)}",
203 file=sys.stderr,
204 )
205 raise SystemExit(ExitCode.USER_ERROR)
206
207 root = require_repo()
208
209 # ── Applicability check ───────────────────────────────────────────────────
210 applicable = True
211 requires_snapshot = record.applicability.get("requires_snapshot", "")
212 if not force and requires_snapshot:
213 try:
214 branch = read_current_branch(root)
215 repo_id = read_repo_id(root)
216 current_snapshot = get_head_snapshot_id(root, repo_id, branch)
217 except Exception:
218 current_snapshot = ""
219 # Initial-commit sentinel (all zeros) is always applicable
220 _sentinel = long_id("0" * 64)
221 if requires_snapshot != _sentinel and current_snapshot != requires_snapshot:
222 applicable = False
223
224 if check_only:
225 if output_json:
226 print(_json.dumps({
227 **make_envelope(elapsed),
228 "patch_id": record.patch_id,
229 "applicable": applicable,
230 "requires_snapshot": requires_snapshot,
231 }, separators=(",", ":")))
232 else:
233 status = "applicable" if applicable else "not applicable"
234 print(f"{'✅' if applicable else '❌'} Patch {status}")
235 return
236
237 if not applicable:
238 print(
239 f"❌ Patch is not applicable: current snapshot does not match "
240 f"requires_snapshot.\n"
241 f" Use --force to bypass this check.",
242 file=sys.stderr,
243 )
244 raise SystemExit(ExitCode.USER_ERROR)
245
246 # ── Restore / delete files ────────────────────────────────────────────────
247 files_applied: list[str] = []
248 files_deleted_applied: list[str] = []
249
250 to_manifest = record.to_manifest
251 files_deleted = record.files_deleted
252
253 if not dry_run:
254 # Seed the local object store with embedded blobs from the patch.
255 for oid, b64_content in record.blobs.items():
256 try:
257 content = base64.b64decode(b64_content)
258 write_object(root, oid, content)
259 except Exception as exc:
260 logger.debug("apply-patch: could not seed object %s: %s", short_id(oid), exc)
261
262 # Restore added and modified files from object store.
263 for rel_path, object_id in to_manifest.items():
264 dest = root / rel_path
265 dest.parent.mkdir(parents=True, exist_ok=True)
266 try:
267 restore_object(root, object_id, dest)
268 files_applied.append(rel_path)
269 except Exception as exc:
270 print(
271 f"❌ Could not restore {sanitize_display(rel_path)}: {exc}",
272 file=sys.stderr,
273 )
274 raise SystemExit(ExitCode.IO_ERROR)
275
276 # Delete files no longer in the target manifest.
277 for rel_path in files_deleted:
278 dest = root / rel_path
279 if dest.exists():
280 try:
281 dest.unlink()
282 files_deleted_applied.append(rel_path)
283 except OSError as exc:
284 logger.debug("apply-patch: could not delete %s: %s", rel_path, exc)
285 else:
286 files_applied = sorted(to_manifest.keys())
287 files_deleted_applied = list(files_deleted)
288
289
290 if output_json:
291 print(_json.dumps({
292 **make_envelope(elapsed),
293 "patch_id": record.patch_id,
294 "files_applied": files_applied,
295 "files_deleted": files_deleted_applied,
296 "dry_run": dry_run,
297 "applicable": applicable,
298 }, separators=(",", ":")))
299 else:
300 if files_applied or files_deleted_applied:
301 prefix = "[dry-run] " if dry_run else ""
302 for p in files_applied:
303 print(f"{prefix}✅ {sanitize_display(p)}")
304 for p in files_deleted_applied:
305 print(f"{prefix}🗑 {sanitize_display(p)}")
306 else:
307 print("✅ Patch applied (no file changes)")
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago