revert.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 only
11 muse revert <ref> --dry-run β€” simulate without writing anything
12
13 JSON output (``--format json`` or ``--json``)::
14
15 {
16 "status": "reverted | applied",
17 "commit_id": "<sha256> | null",
18 "branch": "<current-branch>",
19 "ref": "<ref-as-passed>",
20 "reverted_commit_id": "<sha256>",
21 "snapshot_id": "<sha256>",
22 "message": "<revert-commit-message>",
23 "no_commit": false,
24 "dry_run": false
25 }
26
27 The schema is identical for all paths (normal, ``--no-commit``,
28 ``--dry-run``); only ``status``, ``commit_id``, ``no_commit``, and
29 ``dry_run`` vary.
30
31 Exit codes::
32
33 0 β€” success (reverted, applied to workdir, or dry-run)
34 1 β€” ref not found, root commit, invalid format
35 3 β€” internal error (parent commit or snapshot missing)
36 """
37
38 from __future__ import annotations
39
40 import argparse
41 import datetime
42 import json
43 import logging
44 import sys
45
46 from muse.core.errors import ExitCode
47 from muse.core.reflog import append_reflog
48 from muse.core.repo import read_repo_id, require_repo
49 from muse.core.snapshot import compute_commit_id
50 from muse.core.store import (
51 CommitRecord,
52 get_head_commit_id,
53 read_commit,
54 read_current_branch,
55 read_snapshot,
56 resolve_commit_ref,
57 write_branch_ref,
58 write_commit,
59 )
60 from muse.core.validation import sanitize_display, validate_branch_name
61 from muse.core.workdir import apply_manifest
62 from muse.cli.guard import require_clean_workdir
63
64 logger = logging.getLogger(__name__)
65
66
67 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
68 """Register the ``muse revert`` subcommand and all its flags."""
69 parser = subparsers.add_parser(
70 "revert",
71 help="Create a new commit that undoes a prior commit.",
72 description=__doc__,
73 formatter_class=argparse.RawDescriptionHelpFormatter,
74 )
75 parser.add_argument("ref", help="Commit to revert (ID, HEAD, HEAD~N).")
76 parser.add_argument(
77 "-m", "--message", default=None,
78 help="Override the revert commit message.",
79 )
80 parser.add_argument(
81 "--no-commit", "-n", action="store_true", dest="no_commit",
82 help="Apply the revert to the working tree without creating a commit.",
83 )
84 parser.add_argument(
85 "--force", action="store_true",
86 help="Proceed even if the working tree has uncommitted changes.",
87 )
88 parser.add_argument(
89 "--dry-run", action="store_true", dest="dry_run",
90 help=(
91 "Simulate the revert without writing anything. "
92 "Validates the target ref and its parent snapshot."
93 ),
94 )
95 parser.add_argument(
96 "--format", "-f", default="text", dest="fmt",
97 help="Output format: text (default) or json.",
98 )
99 parser.add_argument(
100 "--json", action="store_const", const="json", dest="fmt",
101 help="Shorthand for --format json.",
102 )
103 parser.set_defaults(func=run)
104
105
106 def run(args: argparse.Namespace) -> None:
107 """Create a new commit that undoes a prior commit.
108
109 All error messages are written to **stderr**; stdout is reserved for
110 structured output only.
111
112 Agents should pass ``--format json`` for a stable, machine-readable
113 result with a consistent schema across all paths::
114
115 {
116 "status": "reverted | applied",
117 "commit_id": "<sha256> | null",
118 "branch": "<current-branch>",
119 "ref": "<ref-as-passed>",
120 "reverted_commit_id": "<sha256>",
121 "snapshot_id": "<sha256>",
122 "message": "<revert-commit-message>",
123 "no_commit": false,
124 "dry_run": false
125 }
126
127 Pass ``--dry-run`` to validate the target ref and its parent snapshot
128 without touching the working tree or creating any commit.
129
130 Pass ``--no-commit`` to apply the revert to the working tree and stage it
131 for a subsequent ``muse commit``.
132
133 Exit codes::
134
135 0 β€” success (reverted, applied, or dry-run)
136 1 β€” ref not found, root commit, invalid format
137 3 β€” internal error (parent commit or snapshot missing)
138 """
139 ref: str = args.ref
140 message: str | None = args.message
141 no_commit: bool = args.no_commit
142 force: bool = args.force
143 dry_run: bool = getattr(args, "dry_run", False)
144 fmt: str = args.fmt
145
146 if fmt not in ("text", "json"):
147 print(
148 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
149 file=sys.stderr,
150 )
151 raise SystemExit(ExitCode.USER_ERROR)
152
153 root = require_repo()
154 # Dry-run never touches the working tree.
155 if not dry_run:
156 require_clean_workdir(root, "revert", force=force)
157 repo_id = read_repo_id(root)
158 branch = read_current_branch(root)
159
160 try:
161 validate_branch_name(branch)
162 except ValueError as exc:
163 print(
164 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
165 file=sys.stderr,
166 )
167 raise SystemExit(ExitCode.INTERNAL_ERROR)
168
169 target = resolve_commit_ref(root, repo_id, branch, ref)
170 if target is None:
171 print(
172 f"❌ Commit '{sanitize_display(ref)}' not found.",
173 file=sys.stderr,
174 )
175 raise SystemExit(ExitCode.USER_ERROR)
176
177 if target.parent_commit_id is None:
178 print(
179 "❌ Cannot revert the root commit (no parent to restore).",
180 file=sys.stderr,
181 )
182 raise SystemExit(ExitCode.USER_ERROR)
183
184 parent_commit = read_commit(root, target.parent_commit_id)
185 if parent_commit is None:
186 print(
187 f"❌ Parent commit {target.parent_commit_id[:8]} not found.",
188 file=sys.stderr,
189 )
190 raise SystemExit(ExitCode.INTERNAL_ERROR)
191
192 target_snapshot = read_snapshot(root, parent_commit.snapshot_id)
193 if target_snapshot is None:
194 print(
195 f"❌ Snapshot {parent_commit.snapshot_id[:8]} not found.",
196 file=sys.stderr,
197 )
198 raise SystemExit(ExitCode.INTERNAL_ERROR)
199
200 # Sanitize the original commit message before embedding it in the revert
201 # commit message, which is stored permanently on disk.
202 safe_original_message = sanitize_display(target.message.splitlines()[0])
203 revert_message = message or f'Revert "{safe_original_message}"'
204
205 # The parent snapshot is already content-addressed in the object store β€”
206 # reuse its snapshot_id directly rather than re-scanning the workdir.
207 snapshot_id = parent_commit.snapshot_id
208
209 # Dry-run: validate succeeded β€” report what would happen and exit.
210 if dry_run:
211 if fmt == "json":
212 print(json.dumps({
213 "status": "reverted",
214 "commit_id": None,
215 "branch": branch,
216 "ref": ref,
217 "reverted_commit_id": target.commit_id,
218 "snapshot_id": snapshot_id,
219 "message": revert_message,
220 "no_commit": no_commit,
221 "dry_run": True,
222 }))
223 else:
224 print(
225 f"[dry-run] Would revert '{sanitize_display(ref)}' "
226 f"({target.commit_id[:8]}) on '{sanitize_display(branch)}'"
227 )
228 return
229
230 head_commit_id = get_head_commit_id(root, branch)
231
232 if no_commit:
233 apply_manifest(root, target_snapshot.manifest)
234 if fmt == "json":
235 print(json.dumps({
236 "status": "applied",
237 "commit_id": None,
238 "branch": branch,
239 "ref": ref,
240 "reverted_commit_id": target.commit_id,
241 "snapshot_id": snapshot_id,
242 "message": revert_message,
243 "no_commit": True,
244 "dry_run": False,
245 }))
246 else:
247 print(
248 f"Revert of {target.commit_id[:8]} applied to working tree. "
249 f"Run 'muse commit' to record."
250 )
251 return
252
253 # Correct write ordering for atomicity:
254 # 1. Compute commit record (all data validated above β€” no I/O failures possible here).
255 # 2. write_commit (idempotent β€” crash here leaves the workdir unchanged).
256 # 3. apply_manifest (workdir is modified only after the commit is durably stored).
257 # 4. write_branch_ref (branch pointer advances last β€” visible to others only when complete).
258 # 5. append_reflog (non-critical audit trail β€” never blocks success).
259 committed_at = datetime.datetime.now(datetime.timezone.utc)
260 commit_id = compute_commit_id(
261 parent_ids=[head_commit_id] if head_commit_id else [],
262 snapshot_id=snapshot_id,
263 message=revert_message,
264 committed_at_iso=committed_at.isoformat(),
265 )
266
267 write_commit(root, CommitRecord(
268 commit_id=commit_id,
269 repo_id=repo_id,
270 branch=branch,
271 snapshot_id=snapshot_id,
272 message=revert_message,
273 committed_at=committed_at,
274 parent_commit_id=head_commit_id,
275 ))
276 apply_manifest(root, target_snapshot.manifest)
277 write_branch_ref(root, branch, commit_id)
278 append_reflog(
279 root, branch, old_id=head_commit_id, new_id=commit_id,
280 author="user",
281 operation=f"revert: {sanitize_display(ref)} β†’ {commit_id[:12]}",
282 )
283
284 if fmt == "json":
285 print(json.dumps({
286 "status": "reverted",
287 "commit_id": commit_id,
288 "branch": branch,
289 "ref": ref,
290 "reverted_commit_id": target.commit_id,
291 "snapshot_id": snapshot_id,
292 "message": revert_message,
293 "no_commit": False,
294 "dry_run": False,
295 }))
296 else:
297 print(
298 f"[{sanitize_display(branch)} {commit_id[:8]}] "
299 f"{sanitize_display(revert_message)}"
300 )