gabriel / muse public
revert.py python
526 lines 19.7 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """``muse revert`` — create a new commit that undoes a prior commit.
2
3 Revert is the safe undo: instead of rewriting history (as ``muse reset``
4 does), revert appends a new commit whose snapshot matches the target
5 commit's parent snapshot. The branch history stays linear and auditable.
6
7 Usage::
8
9 muse revert <ref> — revert and commit immediately
10 muse revert <ref> --no-commit — apply the revert to the working tree and
11 stage changes for the next commit
12 muse revert <ref> --dry-run — simulate without writing anything
13
14 Ref formats accepted::
15
16 HEAD — most recent commit on the current branch
17 HEAD~1 — one commit before HEAD (if supported by resolve_commit_ref)
18 sha256:… — full content-addressed commit ID
19 <hex-prefix> — unambiguous prefix (min 7 chars) of a commit ID
20
21 JSON output (``--format json`` or ``--json``)::
22
23 {
24 "status": "reverted" | "applied",
25 "commit_id": "<sha256>" | null,
26 "branch": "<current-branch>",
27 "ref": "<ref-as-passed>",
28 "reverted_commit_id": "<sha256>",
29 "snapshot_id": "<sha256>",
30 "message": "<revert-commit-message>",
31 "no_commit": false,
32 "dry_run": false,
33 "files_added": ["path/restored.py", ...],
34 "files_modified": ["path/changed.py", ...],
35 "files_removed": ["path/deleted.py", ...],
36 "duration_ms": 12.3,
37 "exit_code": 0
38 }
39
40 The schema is **identical** for all paths (normal, ``--no-commit``,
41 ``--dry-run``); only ``status``, ``commit_id``, ``no_commit``, ``dry_run``,
42 and the file-diff lists vary. ``duration_ms`` and ``exit_code`` are always
43 present — including on error responses — so agents can parse the outcome
44 without inspecting the shell exit status.
45
46 File-diff fields
47 ----------------
48 ``files_added`` — paths present in the parent snapshot but absent from the
49 current HEAD snapshot; these files will be restored on disk.
50 ``files_modified`` — paths in both snapshots but with different object IDs;
51 these files will be overwritten with the older content.
52 ``files_removed`` — paths in the current HEAD snapshot but absent from the
53 parent snapshot; these files will be deleted from disk.
54
55 Agent recipe::
56
57 result = muse revert HEAD --json
58 if result["exit_code"] != 0:
59 handle_error(result)
60 log(f"reverted {result['reverted_commit_id'][:20]} in {result['duration_ms']:.1f}ms")
61 log(f"+{len(result['files_added'])} -{len(result['files_removed'])} ~{len(result['files_modified'])}")
62
63 Exit codes::
64
65 0 — success (reverted, applied to workdir, or dry-run)
66 1 — ref not found, root commit, invalid format
67 2 — not a Muse repository
68 3 — internal error (parent commit or snapshot missing)
69 """
70
71 import argparse
72 import datetime
73 import json
74 import logging
75 import pathlib
76 import sys
77 from muse.core.types import short_id
78 from muse.core.envelope import EnvelopeJson, make_envelope
79 from muse.core.errors import ExitCode
80 from muse.core.reflog import append_reflog
81 from muse.core.repo import read_repo_id, require_repo
82 from muse.cli.config import get_config_value
83 from muse.core.validation import sanitize_provenance
84 from muse.core.snapshot import compute_commit_id
85 from muse.core.store import (
86 CommitRecord,
87 Manifest,
88 RefConflictError,
89 get_head_commit_id,
90 read_commit,
91 read_current_branch,
92 read_snapshot,
93 resolve_commit_ref,
94 write_branch_ref,
95 write_commit,
96 )
97 from muse.core.validation import sanitize_display, validate_branch_name
98 from muse.core.workdir import apply_manifest
99 from muse.core.timing import start_timer
100 from muse.cli.guard import require_clean_workdir
101 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
102
103 logger = logging.getLogger(__name__)
104
105 # ---------------------------------------------------------------------------
106 # JSON wire format
107 # ---------------------------------------------------------------------------
108
109 class _RevertJson(EnvelopeJson, total=False):
110 """JSON output schema — identical across all paths including errors."""
111
112 status: str # "reverted" | "applied" | "error"
113 commit_id: str | None # null on --no-commit / --dry-run / error
114 branch: str
115 ref: str
116 reverted_commit_id: str | None
117 snapshot_id: str | None
118 message: str
119 no_commit: bool
120 dry_run: bool
121 files_added: list[str] # restored from parent snapshot
122 files_modified: list[str] # content changed relative to current HEAD
123 files_removed: list[str] # deleted to match parent snapshot
124
125 # ---------------------------------------------------------------------------
126 # Helpers
127 # ---------------------------------------------------------------------------
128
129 def _compute_diff(
130 current_manifest: Manifest,
131 target_manifest: Manifest,
132 ) -> tuple[list[str], list[str], list[str]]:
133 """Return (files_added, files_modified, files_removed) for the revert.
134
135 The *revert* moves the working tree FROM *current_manifest* TO
136 *target_manifest* (the parent snapshot).
137
138 - ``files_added`` — in target but not in current (will be restored)
139 - ``files_modified`` — in both but different object IDs (will be overwritten)
140 - ``files_removed`` — in current but not in target (will be deleted)
141 """
142 current_keys = set(current_manifest)
143 target_keys = set(target_manifest)
144
145 files_added = sorted(target_keys - current_keys)
146 files_removed = sorted(current_keys - target_keys)
147 files_modified = sorted(
148 p for p in current_keys & target_keys
149 if current_manifest[p] != target_manifest[p]
150 )
151 return files_added, files_modified, files_removed
152
153 def _apply_manifest_safe(
154 root: pathlib.Path,
155 prev_manifest: Manifest,
156 target_manifest: Manifest,
157 ) -> None:
158 """Apply *target_manifest* to the working tree, handling the empty-target case.
159
160 ``apply_manifest`` guards against accidentally applying an empty manifest
161 when there are previously tracked files. In the revert context an empty
162 target is always intentional — the caller validated that we're reverting to
163 a known parent snapshot. We handle the empty case by removing all files
164 that were in *prev_manifest*.
165 """
166 if not target_manifest:
167 from muse.core.validation import contain_path
168 for rel_posix in prev_manifest:
169 fp = root / rel_posix
170 if fp.exists():
171 fp.unlink()
172 else:
173 apply_manifest(root, prev_manifest, target_manifest)
174
175 def _stage_revert(
176 root: pathlib.Path,
177 current_manifest: Manifest,
178 target_manifest: Manifest,
179 files_added: list[str],
180 files_modified: list[str],
181 files_removed: list[str],
182 ) -> None:
183 """Update the stage index to reflect the reverted state.
184
185 Called by the ``--no-commit`` path so that ``muse status`` shows staged
186 changes and ``muse commit`` can record them without a separate
187 ``muse code add`` step.
188
189 - Paths in *files_removed* → staged as ``D`` (deleted).
190 - Paths in *files_added* → staged as ``A`` (added, with the target object ID).
191 - Paths in *files_modified* → staged as ``M`` (modified, with the target object ID).
192 """
193 stage: StagedFileMap = dict(read_stage(root))
194 for path in files_removed:
195 if path in current_manifest:
196 # Was in HEAD → mark as deleted.
197 stage[path] = make_entry(object_id="", mode="D")
198 else:
199 stage.pop(path, None)
200 for path in files_added:
201 stage[path] = make_entry(object_id=target_manifest[path], mode="A")
202 for path in files_modified:
203 stage[path] = make_entry(object_id=target_manifest[path], mode="M")
204 write_stage(root, stage)
205
206 # ---------------------------------------------------------------------------
207 # Command registration
208 # ---------------------------------------------------------------------------
209
210 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
211 """Register the ``muse revert`` subcommand and all its flags."""
212 parser = subparsers.add_parser(
213 "revert",
214 help="Create a new commit that undoes a prior commit.",
215 description=__doc__,
216 formatter_class=argparse.RawDescriptionHelpFormatter,
217 )
218 parser.add_argument("ref", help="Commit to revert (ID, HEAD, HEAD~N).")
219 parser.add_argument(
220 "-m", "--message", default=None,
221 help="Override the revert commit message.",
222 )
223 parser.add_argument(
224 "--no-commit", action="store_true", dest="no_commit",
225 help=(
226 "Apply the revert to the working tree and stage the changes "
227 "without creating a commit. Run 'muse commit' afterwards."
228 ),
229 )
230 parser.add_argument(
231 "--force", action="store_true",
232 help="Proceed even if the working tree has uncommitted changes.",
233 )
234 parser.add_argument(
235 "--dry-run", "-n", action="store_true", dest="dry_run",
236 help=(
237 "Simulate the revert without writing anything. "
238 "Validates the target ref and its parent snapshot."
239 ),
240 )
241 parser.add_argument(
242 "--json", "-j", action="store_true", dest="json_out",
243 help="Emit machine-readable JSON instead of human text.",
244 )
245 parser.set_defaults(func=run)
246
247 # ---------------------------------------------------------------------------
248 # Main handler
249 # ---------------------------------------------------------------------------
250
251 def run(args: argparse.Namespace) -> None:
252 """Create a new commit that undoes a prior commit.
253
254 All error messages are written to **stderr**; stdout is reserved for
255 structured output only. ``files_added`` / ``files_modified`` /
256 ``files_removed`` are populated on all three paths (normal,
257 ``--no-commit``, ``--dry-run``).
258
259 Agent quickstart::
260
261 muse revert HEAD --json
262 muse revert HEAD --no-commit --json
263 muse revert HEAD --dry-run --json
264 muse revert sha256:abc123 --json
265
266 JSON fields::
267
268 status str "reverted" | "applied" | "error"
269 commit_id str|null New revert commit ID; null for --no-commit/--dry-run
270 branch str Current branch name
271 ref str Ref as passed by the caller
272 reverted_commit_id str|null Commit ID that was reverted
273 snapshot_id str|null Snapshot ID restored by the revert
274 message str Revert commit message
275 no_commit bool True when --no-commit was given
276 dry_run bool True when no writes were made
277 files_added list[str] Paths restored from the parent snapshot
278 files_modified list[str] Paths overwritten with older content
279 files_removed list[str] Paths deleted to match the parent snapshot
280
281 Exit codes::
282
283 0 Success (reverted, applied, or dry-run).
284 1 Ref not found, root commit, or invalid format.
285 2 Not a Muse repository.
286 3 Internal error (parent commit or snapshot missing).
287 """
288 elapsed = start_timer()
289
290 ref: str = args.ref
291 message: str | None = args.message
292 no_commit: bool = args.no_commit
293 force: bool = args.force
294 dry_run: bool = getattr(args, "dry_run", False)
295 json_out: bool = args.json_out
296
297 def _emit_error(exit_code: int, error_key: str, msg: str, **extra: str) -> None:
298 """Emit a JSON error envelope on stdout when json_out is True."""
299 if json_out:
300 payload = {
301 **make_envelope(elapsed, exit_code=exit_code),
302 "status": "error",
303 "commit_id": None,
304 "branch": "",
305 "ref": ref,
306 "reverted_commit_id": None,
307 "snapshot_id": None,
308 "message": msg,
309 "no_commit": no_commit,
310 "dry_run": dry_run,
311 "files_added": [],
312 "files_modified": [],
313 "files_removed": [],
314 "error": error_key,
315 **extra,
316 }
317 print(json.dumps(payload))
318
319 root = require_repo()
320 # Dry-run never touches the working tree.
321 if not dry_run:
322 require_clean_workdir(root, "revert", force=force, json_out=json_out)
323 repo_id = read_repo_id(root)
324 branch = read_current_branch(root)
325
326 try:
327 validate_branch_name(branch)
328 except ValueError as exc:
329 _emit_error(ExitCode.INTERNAL_ERROR, "invalid_branch", str(exc))
330 print(
331 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
332 file=sys.stderr,
333 )
334 raise SystemExit(ExitCode.INTERNAL_ERROR)
335
336 target = resolve_commit_ref(root, repo_id, branch, ref)
337 if target is None:
338 _emit_error(
339 ExitCode.USER_ERROR, "commit_not_found",
340 f"commit '{ref}' not found",
341 branch=branch,
342 )
343 print(
344 f"❌ Commit '{sanitize_display(ref)}' not found.",
345 file=sys.stderr,
346 )
347 raise SystemExit(ExitCode.USER_ERROR)
348
349 if target.parent_commit_id is None:
350 _emit_error(
351 ExitCode.USER_ERROR, "root_commit",
352 "cannot revert the root commit (no parent to restore)",
353 branch=branch,
354 reverted_commit_id=target.commit_id,
355 )
356 print(
357 "❌ Cannot revert the root commit (no parent to restore).",
358 file=sys.stderr,
359 )
360 raise SystemExit(ExitCode.USER_ERROR)
361
362 parent_commit = read_commit(root, target.parent_commit_id)
363 if parent_commit is None:
364 _emit_error(
365 ExitCode.INTERNAL_ERROR, "parent_not_found",
366 f"parent commit {target.parent_commit_id} not found",
367 branch=branch,
368 reverted_commit_id=target.commit_id,
369 )
370 print(
371 f"❌ Parent commit {target.parent_commit_id} not found.",
372 file=sys.stderr,
373 )
374 raise SystemExit(ExitCode.INTERNAL_ERROR)
375
376 target_snapshot = read_snapshot(root, parent_commit.snapshot_id)
377 if target_snapshot is None:
378 _emit_error(
379 ExitCode.INTERNAL_ERROR, "snapshot_missing",
380 f"snapshot {parent_commit.snapshot_id} not found",
381 branch=branch,
382 reverted_commit_id=target.commit_id,
383 )
384 print(
385 f"❌ Snapshot {parent_commit.snapshot_id} not found.",
386 file=sys.stderr,
387 )
388 raise SystemExit(ExitCode.INTERNAL_ERROR)
389
390 # Read the current HEAD snapshot to compute the file-level diff.
391 head_commit_id = get_head_commit_id(root, branch)
392 current_manifest: Manifest = {}
393 if head_commit_id:
394 head_commit = read_commit(root, head_commit_id)
395 if head_commit:
396 head_snap = read_snapshot(root, head_commit.snapshot_id)
397 if head_snap:
398 current_manifest = dict(head_snap.manifest)
399
400 # Compute the file-level diff: current → parent (target of revert).
401 files_added, files_modified, files_removed = _compute_diff(
402 current_manifest, dict(target_snapshot.manifest)
403 )
404
405 # Sanitize the original commit message before embedding it in the revert
406 # commit message, which is stored permanently on disk.
407 safe_original_message = sanitize_display(target.message.splitlines()[0])
408 revert_message = message or f'Revert "{safe_original_message}"'
409
410 # The parent snapshot is already content-addressed in the object store —
411 # reuse its snapshot_id directly rather than re-scanning the workdir.
412 snapshot_id = parent_commit.snapshot_id
413
414 # Dry-run: validate succeeded — report what would happen and exit.
415 if dry_run:
416 if json_out:
417 print(json.dumps(_RevertJson(
418 **make_envelope(elapsed),
419 status="reverted",
420 commit_id=None,
421 branch=branch,
422 ref=ref,
423 reverted_commit_id=target.commit_id,
424 snapshot_id=snapshot_id,
425 message=revert_message,
426 no_commit=no_commit,
427 dry_run=True,
428 files_added=files_added,
429 files_modified=files_modified,
430 files_removed=files_removed,
431 )))
432 else:
433 print(
434 f"[dry-run] Would revert '{sanitize_display(ref)}' "
435 f"({target.commit_id}) on '{sanitize_display(branch)}'"
436 )
437 return
438
439 if no_commit:
440 _apply_manifest_safe(root, current_manifest, dict(target_snapshot.manifest))
441 _stage_revert(
442 root, current_manifest, dict(target_snapshot.manifest),
443 files_added, files_modified, files_removed,
444 )
445 if json_out:
446 print(json.dumps(_RevertJson(
447 **make_envelope(elapsed),
448 status="applied",
449 commit_id=None,
450 branch=branch,
451 ref=ref,
452 reverted_commit_id=target.commit_id,
453 snapshot_id=snapshot_id,
454 message=revert_message,
455 no_commit=True,
456 dry_run=False,
457 files_added=files_added,
458 files_modified=files_modified,
459 files_removed=files_removed,
460 )))
461 else:
462 print(
463 f"Revert of {target.commit_id} applied to working tree. "
464 f"Run 'muse commit' to record."
465 )
466 return
467
468 # Correct write ordering for atomicity:
469 # 1. Compute commit record (all data validated above — no I/O failures possible here).
470 # 2. write_commit (idempotent — crash here leaves the workdir unchanged).
471 # 3. apply_manifest (workdir is modified only after the commit is durably stored).
472 # 4. write_branch_ref (branch pointer advances last — visible to others only when complete).
473 # 5. append_reflog (non-critical audit trail — never blocks success).
474 committed_at = datetime.datetime.now(datetime.timezone.utc)
475 revert_author = sanitize_provenance(get_config_value("user.handle", root) or "")
476 commit_id = compute_commit_id(
477 parent_ids=[head_commit_id] if head_commit_id else [],
478 snapshot_id=snapshot_id,
479 message=revert_message,
480 committed_at_iso=committed_at.isoformat(),
481 author=revert_author,
482 )
483
484 write_commit(root, CommitRecord(
485 commit_id=commit_id,
486 repo_id=repo_id,
487 branch=branch,
488 snapshot_id=snapshot_id,
489 message=revert_message,
490 committed_at=committed_at,
491 parent_commit_id=head_commit_id,
492 author=revert_author,
493 ))
494 _apply_manifest_safe(root, current_manifest, dict(target_snapshot.manifest))
495 try:
496 write_branch_ref(root, branch, commit_id, expected_id=head_commit_id)
497 except RefConflictError as exc:
498 print(f"❌ {exc}", file=sys.stderr)
499 raise SystemExit(ExitCode.USER_ERROR)
500 append_reflog(
501 root, branch, old_id=head_commit_id, new_id=commit_id,
502 author="user",
503 operation=f"revert: {sanitize_display(ref)} → {commit_id}",
504 )
505
506 if json_out:
507 print(json.dumps(_RevertJson(
508 **make_envelope(elapsed),
509 status="reverted",
510 commit_id=commit_id,
511 branch=branch,
512 ref=ref,
513 reverted_commit_id=target.commit_id,
514 snapshot_id=snapshot_id,
515 message=revert_message,
516 no_commit=False,
517 dry_run=False,
518 files_added=files_added,
519 files_modified=files_modified,
520 files_removed=files_removed,
521 )))
522 else:
523 print(
524 f"[{sanitize_display(branch)} {short_id(commit_id)}] "
525 f"{sanitize_display(revert_message)}"
526 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago