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