gabriel / muse public
apply_patch.py python
314 lines 11.1 KB
Raw
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 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 import argparse
58 import base64
59 import json as _json
60 import logging
61 import pathlib
62 import sys
63
64 from muse.core.types import NULL_LONG_ID, long_id, short_id
65 from muse.core.errors import ExitCode
66 from muse.core.object_store import restore_object, write_object
67 from muse.core.patch_record import PatchRecord, compute_patch_id, deserialize_patch
68 from muse.core.repo import require_repo
69 from muse.core.refs import read_current_branch
70 from muse.core.commits import get_head_snapshot_id
71 from muse.core.validation import sanitize_display
72 from muse.core.envelope import EnvelopeJson, make_envelope
73 from muse.core.timing import start_timer
74 from typing import TypedDict
75
76 logger = logging.getLogger(__name__)
77
78 class _CheckJson(EnvelopeJson):
79 """JSON output for ``muse apply-patch --check-only --json``."""
80
81 patch_id: str
82 applicable: bool
83 requires_snapshot: str
84
85 class _ApplyPatchJson(EnvelopeJson):
86 """JSON output for ``muse apply-patch --json``."""
87
88 patch_id: str
89 files_applied: list[str]
90 files_deleted: list[str]
91 dry_run: bool
92 applicable: bool
93
94 # ---------------------------------------------------------------------------
95 # Registration
96 # ---------------------------------------------------------------------------
97
98 def register(
99 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
100 ) -> None:
101 """Register the ``muse apply-patch`` subcommand."""
102 parser = subparsers.add_parser(
103 "apply-patch",
104 help="Apply a Muse .mpatch file to the working tree.",
105 description=__doc__,
106 formatter_class=argparse.RawDescriptionHelpFormatter,
107 )
108 parser.add_argument(
109 "patch_file",
110 metavar="FILE",
111 help="Path to the .mpatch file to apply.",
112 )
113 parser.add_argument(
114 "--dry-run", "-n",
115 action="store_true",
116 dest="dry_run",
117 help="Report what would change without touching disk.",
118 )
119 parser.add_argument(
120 "--check",
121 action="store_true",
122 dest="check_only",
123 help="Report applicability only; do not apply the patch.",
124 )
125 parser.add_argument(
126 "--force", "-f",
127 action="store_true",
128 dest="force",
129 help="Bypass the requires_snapshot applicability check.",
130 )
131 parser.add_argument(
132 "--json", "-j",
133 action="store_true",
134 dest="json_out",
135 help="Emit a JSON result object to stdout.",
136 )
137 parser.set_defaults(func=run)
138
139 # ---------------------------------------------------------------------------
140 # Run
141 # ---------------------------------------------------------------------------
142
143 def run(args: argparse.Namespace) -> None:
144 """Apply a Muse ``.mpatch`` file to the working tree.
145
146 Verifies patch integrity (``patch_id`` SHA-256), checks applicability
147 against the current HEAD snapshot, restores added/modified files from the
148 object store, and deletes files listed as removed. The result is a dirty
149 working tree ready for ``muse commit``.
150
151 Agent quickstart
152 ----------------
153 ::
154
155 muse apply-patch patch.mpatch --json
156 muse apply-patch patch.mpatch --dry-run --json # preview only
157 muse apply-patch patch.mpatch --check --json # applicability only
158 muse apply-patch patch.mpatch --force --json # skip snapshot check
159
160 JSON fields
161 -----------
162 patch_id SHA-256 patch identifier (``sha256:<hex>``).
163 files_applied List of file paths restored or created.
164 files_deleted List of file paths removed.
165 dry_run ``true`` when ``--dry-run`` was passed (no writes occurred).
166 applicable ``true`` if the current snapshot matches ``requires_snapshot``.
167
168 With ``--check``, only ``patch_id``, ``applicable``, and
169 ``requires_snapshot`` are returned.
170
171 Exit codes
172 ----------
173 0 Patch applied successfully (or dry-run / check passed).
174 1 Patch integrity failure, not applicable, or missing file.
175 2 Not inside a Muse repository.
176 3 I/O error restoring or deleting files.
177 """
178 elapsed = start_timer()
179 patch_path = pathlib.Path(args.patch_file)
180 dry_run: bool = args.dry_run
181 check_only: bool = args.check_only
182 force: bool = args.force
183 json_out: bool = args.json_out
184
185 # ── Load patch file ───────────────────────────────────────────────────────
186 if not patch_path.exists():
187 print(
188 f"❌ Patch file not found: {sanitize_display(str(patch_path))}",
189 file=sys.stderr,
190 )
191 raise SystemExit(ExitCode.USER_ERROR)
192
193 try:
194 raw = patch_path.read_bytes()
195 except OSError as exc:
196 print(f"❌ Could not read patch file: {exc}", file=sys.stderr)
197 raise SystemExit(ExitCode.IO_ERROR)
198
199 try:
200 record: PatchRecord = deserialize_patch(raw)
201 except Exception as exc:
202 print(f"❌ Invalid patch file: {exc}", file=sys.stderr)
203 raise SystemExit(ExitCode.USER_ERROR)
204
205 # ── Verify patch_id integrity ─────────────────────────────────────────────
206 expected_id = compute_patch_id(record)
207 if record.patch_id != expected_id:
208 print(
209 f"❌ Patch integrity check failed.\n"
210 f" Stored: {sanitize_display(record.patch_id)}\n"
211 f" Expected: {sanitize_display(expected_id)}",
212 file=sys.stderr,
213 )
214 raise SystemExit(ExitCode.USER_ERROR)
215
216 root = require_repo()
217
218 # ── Applicability check ───────────────────────────────────────────────────
219 applicable = True
220 requires_snapshot = record.applicability.get("requires_snapshot", "")
221 if not force and requires_snapshot:
222 try:
223 branch = read_current_branch(root)
224 current_snapshot = get_head_snapshot_id(root, branch)
225 except Exception:
226 current_snapshot = ""
227 # Initial-commit sentinel (all zeros) is always applicable
228 _sentinel = NULL_LONG_ID
229 if requires_snapshot != _sentinel and current_snapshot != requires_snapshot:
230 applicable = False
231
232 if check_only:
233 if json_out:
234 print(_json.dumps(_CheckJson(
235 **make_envelope(elapsed),
236 patch_id=record.patch_id,
237 applicable=applicable,
238 requires_snapshot=requires_snapshot,
239 ), separators=(",", ":")))
240 else:
241 status = "applicable" if applicable else "not applicable"
242 print(f"{'✅' if applicable else '❌'} Patch {status}")
243 return
244
245 if not applicable:
246 print(
247 f"❌ Patch is not applicable: current snapshot does not match "
248 f"requires_snapshot.\n"
249 f" Use --force to bypass this check.",
250 file=sys.stderr,
251 )
252 raise SystemExit(ExitCode.USER_ERROR)
253
254 # ── Restore / delete files ────────────────────────────────────────────────
255 files_applied: list[str] = []
256 files_deleted_applied: list[str] = []
257
258 to_manifest = record.to_manifest
259 files_deleted = record.files_deleted
260
261 if not dry_run:
262 # Seed the local object store with embedded blobs from the patch.
263 for oid, b64_content in record.blobs.items():
264 try:
265 content = base64.b64decode(b64_content)
266 write_object(root, oid, content)
267 except Exception as exc:
268 logger.debug("apply-patch: could not seed object %s: %s", short_id(oid), exc)
269
270 # Restore added and modified files from object store.
271 for rel_path, object_id in to_manifest.items():
272 dest = root / rel_path
273 dest.parent.mkdir(parents=True, exist_ok=True)
274 try:
275 restore_object(root, object_id, dest)
276 files_applied.append(rel_path)
277 except Exception as exc:
278 print(
279 f"❌ Could not restore {sanitize_display(rel_path)}: {exc}",
280 file=sys.stderr,
281 )
282 raise SystemExit(ExitCode.IO_ERROR)
283
284 # Delete files no longer in the target manifest.
285 for rel_path in files_deleted:
286 dest = root / rel_path
287 if dest.exists():
288 try:
289 dest.unlink()
290 files_deleted_applied.append(rel_path)
291 except OSError as exc:
292 logger.debug("apply-patch: could not delete %s: %s", rel_path, exc)
293 else:
294 files_applied = sorted(to_manifest.keys())
295 files_deleted_applied = list(files_deleted)
296
297 if json_out:
298 print(_json.dumps(_ApplyPatchJson(
299 **make_envelope(elapsed),
300 patch_id=record.patch_id,
301 files_applied=files_applied,
302 files_deleted=files_deleted_applied,
303 dry_run=dry_run,
304 applicable=applicable,
305 ), separators=(",", ":")))
306 else:
307 if files_applied or files_deleted_applied:
308 prefix = "[dry-run] " if dry_run else ""
309 for p in files_applied:
310 print(f"{prefix}✅ {sanitize_display(p)}")
311 for p in files_deleted_applied:
312 print(f"{prefix}🗑 {sanitize_display(p)}")
313 else:
314 print("✅ Patch applied (no file changes)")
File History 1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 71 days ago