gabriel / muse public
update_ref.py python
282 lines 9.3 KB
Raw
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 1 day ago
1 """muse update-ref — move a branch HEAD to a specific commit.
2
3 Directly writes a branch reference file under ``.muse/refs/heads/``. This is
4 the lowest-level way to advance or rewind a branch without any merge logic.
5
6 Commands (``muse commit``, ``muse merge``, ``muse reset``) call this internally
7 after computing the new commit ID.
8
9 Output — create/update::
10
11 {
12 "branch": "main",
13 "commit_id": "sha256:<64-hex>",
14 "previous": "sha256:<64-hex>" | null,
15 "duration_ms": 1.234,
16 "exit_code": 0
17 }
18
19 Output — delete::
20
21 {
22 "branch": "main",
23 "deleted": true,
24 "duration_ms": 0.812,
25 "exit_code": 0
26 }
27
28 Output contract
29 ---------------
30
31 - Exit 0: ref updated or deleted.
32 - Exit 1: commit not found in the store, invalid commit ID format,
33 ``--delete`` on a non-existent ref, CAS mismatch, or invalid args.
34 - Exit 3: file write failure (``OSError``).
35
36 JSON error contract
37 -------------------
38
39 When ``--json`` (or ``--format json``) is active, **all** errors are emitted
40 to **stdout** so agents always receive a parseable response::
41
42 {"error": "<key>", "message": "<text>", "duration_ms": 0.3, "exit_code": 1}
43
44 CAS errors also include ``current`` and ``expected`` fields::
45
46 {
47 "error": "cas_mismatch",
48 "message": "CAS mismatch: ...",
49 "current": "sha256:...",
50 "expected": "sha256:...",
51 "duration_ms": 0.5,
52 "exit_code": 1
53 }
54
55 Agent use — compare-and-swap (CAS)
56 -----------------------------------
57
58 In a multi-agent environment multiple agents may try to advance the same branch
59 concurrently. Use ``--old-value`` to make the update conditional: it succeeds
60 only if the current ref value matches the expected value. This turns update-ref
61 into an atomic compare-and-swap and prevents silent overwrites::
62
63 muse update-ref main <new_id> --old-value <expected_current_id>
64 """
65
66 import argparse
67 import json
68 import logging
69 import sys
70 from muse.core.envelope import EnvelopeJson, make_envelope
71 from muse.core.paths import ref_path as _ref_path
72 from muse.core.errors import ExitCode
73 from muse.core.refs import read_ref
74 from muse.core.repo import require_repo
75 from muse.core.refs import get_head_commit_id, write_branch_ref
76 from muse.core.commits import read_commit
77 from muse.core.validation import validate_branch_name, validate_object_id
78 from muse.core.timing import start_timer
79
80 logger = logging.getLogger(__name__)
81
82 class _UpdateRefJson(EnvelopeJson):
83 """JSON schema for a successful create/update response."""
84 branch: str
85 commit_id: str
86 previous: str | None
87
88 class _DeleteRefJson(EnvelopeJson):
89 """JSON schema for a successful delete response."""
90 branch: str
91 deleted: bool
92
93 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
94 """Register the update-ref subcommand."""
95 parser = subparsers.add_parser(
96 "update-ref",
97 help="Move a branch HEAD to a specific commit ID.",
98 description=__doc__,
99 formatter_class=argparse.RawDescriptionHelpFormatter,
100 )
101 parser.add_argument(
102 "branch",
103 help="Branch name to update.",
104 )
105 parser.add_argument(
106 "commit_id",
107 nargs="?",
108 default=None,
109 help="Commit ID to point the branch at. Omit with --delete to remove the branch.",
110 )
111 parser.add_argument(
112 "--delete", "-d",
113 action="store_true",
114 help="Delete the branch ref entirely.",
115 )
116 parser.add_argument(
117 "--no-verify",
118 dest="verify",
119 action="store_false",
120 help="Skip verifying the commit exists before updating.",
121 )
122 parser.add_argument(
123 "--old-value",
124 dest="old_value",
125 default=None,
126 metavar="COMMIT_ID",
127 help=(
128 "Compare-and-swap guard: update only if the current ref matches this commit ID. "
129 "Use 'null' to require that the ref does not currently exist. "
130 "Essential for safe concurrent updates in multi-agent environments."
131 ),
132 )
133 parser.add_argument(
134 "--json", "-j",
135 action="store_true",
136 dest="json_out",
137 help="Emit machine-readable JSON on stdout.",
138 )
139 parser.set_defaults(func=run, verify=True, json_out=False)
140
141 def run(args: argparse.Namespace) -> None:
142 """Move a branch HEAD to a specific commit ID.
143
144 Directly writes (or deletes) a branch ref file under ``.muse/refs/heads/``.
145 Supports compare-and-swap via ``--old-value`` for safe concurrent updates in
146 multi-agent environments. With ``--no-verify``, skips confirming the commit
147 exists in the store (useful after ``muse unpack-objects``).
148
149 Agent quickstart::
150
151 muse update-ref main sha256:<id> --json
152 muse update-ref main sha256:<new> --old-value sha256:<expected> --json
153 muse update-ref feat/x --delete --json
154
155 JSON fields::
156
157 branch Branch name that was updated or deleted.
158 commit_id New commit ID the branch points at (create/update only).
159 previous Previous commit ID before update, or null (create/update only).
160 deleted true (delete mode only).
161 muse_version Muse release that produced this output.
162 schema Envelope schema version (int).
163 exit_code 0 on success, 1 on user error, 3 on write failure.
164 duration_ms Wall-clock milliseconds for the command.
165 timestamp ISO-8601 UTC timestamp of command completion.
166 warnings List of non-fatal advisory messages.
167
168 Exit codes::
169
170 0 Ref updated or deleted successfully.
171 1 User error (invalid args, commit not found, CAS mismatch).
172 3 Write failure (OSError from write_branch_ref).
173 """
174 elapsed = start_timer()
175 json_out: bool = args.json_out
176 branch: str = args.branch
177 commit_id: str | None = args.commit_id
178 delete: bool = args.delete
179 verify: bool = args.verify
180 old_value: str | None = args.old_value
181
182 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: str) -> None:
183 if json_out:
184 payload = {**make_envelope(elapsed, exit_code=code), "error": error_key, "message": msg}
185 payload.update(extra)
186 print(json.dumps(payload))
187 else:
188 print(f"❌ {msg}", file=sys.stderr)
189 raise SystemExit(code)
190
191 root = require_repo()
192
193 try:
194 validate_branch_name(branch)
195 except ValueError as exc:
196 _emit_error(f"Invalid branch name: {exc}", ExitCode.USER_ERROR, "invalid_branch")
197
198 ref_path = _ref_path(root, branch)
199
200 # Validate --old-value before any write.
201 if old_value is not None and old_value != "null":
202 try:
203 validate_object_id(old_value)
204 except ValueError as exc:
205 _emit_error(f"Invalid --old-value: {exc}", ExitCode.USER_ERROR, "invalid_old_value")
206
207 if delete:
208 if not ref_path.exists():
209 _emit_error(
210 f"Branch ref does not exist: {branch}",
211 ExitCode.USER_ERROR,
212 "ref_not_found",
213 )
214 if old_value is not None:
215 current = read_ref(ref_path) or ""
216 if old_value != current:
217 _emit_error(
218 "CAS mismatch: ref does not match --old-value",
219 ExitCode.USER_ERROR,
220 "cas_mismatch",
221 current=current,
222 expected=old_value,
223 )
224 ref_path.unlink()
225 if json_out:
226 print(json.dumps(_DeleteRefJson(**make_envelope(elapsed), branch=branch, deleted=True)))
227 return
228
229 if commit_id is None:
230 _emit_error(
231 "commit_id is required unless --delete is used.",
232 ExitCode.USER_ERROR,
233 "missing_commit_id",
234 )
235
236 # Always validate the format — writing a malformed ID to a ref file would
237 # silently corrupt the repository regardless of the --verify flag.
238 try:
239 validate_object_id(commit_id)
240 except ValueError as exc:
241 _emit_error(f"Invalid commit ID: {exc}", ExitCode.USER_ERROR, "invalid_commit_id")
242
243 if verify and read_commit(root, commit_id) is None:
244 _emit_error(
245 f"Commit not found in store: {commit_id}",
246 ExitCode.USER_ERROR,
247 "commit_not_found",
248 )
249
250 previous = get_head_commit_id(root, branch)
251
252 # CAS check — must happen after reading `previous` and before the write.
253 if old_value is not None:
254 if old_value == "null":
255 if previous is not None:
256 _emit_error(
257 "CAS mismatch: ref already exists (--old-value null requires no ref)",
258 ExitCode.USER_ERROR,
259 "cas_mismatch",
260 current=previous,
261 )
262 elif old_value != previous:
263 _emit_error(
264 "CAS mismatch: ref does not match --old-value",
265 ExitCode.USER_ERROR,
266 "cas_mismatch",
267 current=previous,
268 expected=old_value,
269 )
270
271 try:
272 write_branch_ref(root, branch, commit_id)
273 except (OSError, ValueError) as exc:
274 _emit_error(str(exc), ExitCode.INTERNAL_ERROR, "write_error")
275
276 if json_out:
277 print(json.dumps(_UpdateRefJson(
278 **make_envelope(elapsed),
279 branch=branch,
280 commit_id=commit_id,
281 previous=previous,
282 )))
File History 1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf chore: bump version to 0.2.0rc14 Sonnet 4.6 patch 1 day ago