gabriel / muse public
reset.py python
230 lines 7.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """``muse reset`` — move HEAD to a prior commit.
2
3 Modes::
4
5 --soft Move the branch pointer only; working tree is untouched.
6 --hard Move the branch pointer AND restore the working tree from the
7 target snapshot. Requires a clean working tree unless ``--force``
8 is also passed.
9
10 Usage::
11
12 muse reset <ref> — soft reset (branch pointer only)
13 muse reset <ref> --hard — hard reset (branch + working tree)
14 muse reset <ref> --dry-run — simulate without writing anything
15
16 JSON output (``--format json`` or ``--json``)::
17
18 {
19 "branch": "<current-branch>",
20 "ref": "<ref-as-passed>",
21 "old_commit_id": "<sha256> | null",
22 "new_commit_id": "<sha256>",
23 "snapshot_id": "<sha256>",
24 "mode": "soft | hard",
25 "dry_run": false
26 }
27
28 Exit codes::
29
30 0 — success (or dry-run with no problems found)
31 1 — ref not found, invalid branch name, invalid format
32 3 — internal error (snapshot missing from object store)
33 """
34
35 from __future__ import annotations
36
37 import argparse
38 import json
39 import logging
40 import sys
41
42 from muse.core.errors import ExitCode
43 from muse.core.repo import read_repo_id, require_repo
44 from muse.core.store import (
45 get_head_commit_id,
46 read_current_branch,
47 read_snapshot,
48 resolve_commit_ref,
49 write_branch_ref,
50 )
51 from muse.core.reflog import append_reflog
52 from muse.core.validation import sanitize_display, validate_branch_name
53 from muse.core.workdir import apply_manifest
54 from muse.cli.guard import require_clean_workdir
55
56 logger = logging.getLogger(__name__)
57
58
59 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
60 """Register the ``muse reset`` subcommand and all its flags."""
61 parser = subparsers.add_parser(
62 "reset",
63 help="Move HEAD to a prior commit.",
64 description=__doc__,
65 formatter_class=argparse.RawDescriptionHelpFormatter,
66 )
67 parser.add_argument("ref", help="Commit ID or ref to reset to (e.g. HEAD~1, a3f2c9).")
68 parser.add_argument(
69 "--hard", action="store_true",
70 help="Reset branch pointer AND restore the working tree from the target snapshot.",
71 )
72 parser.add_argument(
73 "--soft", action="store_true",
74 help="Reset branch pointer only, leaving the working tree unchanged (default).",
75 )
76 parser.add_argument(
77 "--force", action="store_true",
78 help="With --hard: proceed even if the working tree has uncommitted changes.",
79 )
80 parser.add_argument(
81 "--dry-run", action="store_true", dest="dry_run",
82 help=(
83 "Simulate the reset without writing anything. "
84 "Reports what would change and validates the target ref."
85 ),
86 )
87 parser.add_argument(
88 "--format", "-f", default="text", dest="fmt",
89 help="Output format: text (default) or json.",
90 )
91 parser.add_argument(
92 "--json", action="store_const", const="json", dest="fmt",
93 help="Shorthand for --format json.",
94 )
95 parser.set_defaults(func=run)
96
97
98 def run(args: argparse.Namespace) -> None:
99 """Move HEAD to a prior commit.
100
101 All error messages are written to **stderr**; stdout is reserved for
102 structured output only.
103
104 Agents should pass ``--format json`` for a stable, machine-readable result::
105
106 {
107 "branch": "<current-branch>",
108 "ref": "<ref-as-passed>",
109 "old_commit_id": "<sha256> | null",
110 "new_commit_id": "<sha256>",
111 "snapshot_id": "<sha256>",
112 "mode": "soft | hard",
113 "dry_run": false
114 }
115
116 Pass ``--dry-run`` to validate the target ref and preview the reset without
117 writing anything to disk. Exit 0 if the reset would succeed.
118
119 Exit codes::
120
121 0 — success (or successful dry-run)
122 1 — ref not found, invalid branch name, invalid format
123 3 — internal error (target snapshot missing from object store)
124 """
125 ref: str = args.ref
126 hard: bool = args.hard
127 dry_run: bool = getattr(args, "dry_run", False)
128 force: bool = args.force
129 fmt: str = args.fmt
130
131 if fmt not in ("text", "json"):
132 print(
133 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
134 file=sys.stderr,
135 )
136 raise SystemExit(ExitCode.USER_ERROR)
137
138 root = require_repo()
139 # Only --hard modifies the working tree; --soft just moves the branch pointer.
140 # Dry-run never touches the working tree.
141 if hard and not dry_run:
142 require_clean_workdir(root, "reset --hard", force=force)
143
144 repo_id = read_repo_id(root)
145 branch = read_current_branch(root)
146
147 try:
148 validate_branch_name(branch)
149 except ValueError as exc:
150 print(
151 f"❌ Current branch name is invalid: {sanitize_display(str(exc))}",
152 file=sys.stderr,
153 )
154 raise SystemExit(ExitCode.INTERNAL_ERROR)
155
156 commit = resolve_commit_ref(root, repo_id, branch, ref)
157 if commit is None:
158 print(
159 f"❌ '{sanitize_display(ref)}' not found.",
160 file=sys.stderr,
161 )
162 raise SystemExit(ExitCode.USER_ERROR)
163
164 # For --hard, validate the snapshot exists BEFORE writing anything to disk.
165 # This prevents the critical ordering bug where write_branch_ref() succeeds
166 # but apply_manifest() fails, leaving the branch ref pointing at the new
167 # commit while the working tree is only partially restored.
168 snapshot_id = commit.snapshot_id
169 if hard:
170 snapshot = read_snapshot(root, snapshot_id)
171 if snapshot is None:
172 print(
173 f"❌ Snapshot {snapshot_id[:8]} not found in object store "
174 "— aborting to prevent data loss.",
175 file=sys.stderr,
176 )
177 raise SystemExit(ExitCode.INTERNAL_ERROR)
178 else:
179 snapshot = None
180
181 mode = "hard" if hard else "soft"
182 old_commit_id = get_head_commit_id(root, branch)
183
184 # Dry-run: report what would happen without writing anything.
185 if dry_run:
186 if fmt == "json":
187 print(json.dumps({
188 "branch": branch,
189 "ref": ref,
190 "old_commit_id": old_commit_id,
191 "new_commit_id": commit.commit_id,
192 "snapshot_id": snapshot_id,
193 "mode": mode,
194 "dry_run": True,
195 }))
196 else:
197 label = f"[dry-run] Would reset '{sanitize_display(branch)}'"
198 msg_snip = sanitize_display(commit.message.splitlines()[0][:60])
199 print(f"{label} ({mode}) to {commit.commit_id[:8]} {msg_snip}")
200 return
201
202 # Write the branch ref first for --soft (no snapshot dependency).
203 # For --hard: snapshot was already validated above — write ref then restore.
204 write_branch_ref(root, branch, commit.commit_id)
205 append_reflog(
206 root, branch, old_id=old_commit_id, new_id=commit.commit_id,
207 author="user",
208 operation=f"reset ({mode}): moving to {commit.commit_id[:12]}",
209 )
210
211 if hard and snapshot is not None:
212 apply_manifest(root, snapshot.manifest)
213
214 if fmt == "json":
215 print(json.dumps({
216 "branch": branch,
217 "ref": ref,
218 "old_commit_id": old_commit_id,
219 "new_commit_id": commit.commit_id,
220 "snapshot_id": snapshot_id,
221 "mode": mode,
222 "dry_run": False,
223 }))
224 elif hard:
225 print(
226 f"HEAD is now at {commit.commit_id[:8]} "
227 f"{sanitize_display(commit.message.splitlines()[0])}"
228 )
229 else:
230 print(f"Moved {sanitize_display(branch)} to {commit.commit_id[:8]}")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago