update_ref.py
python
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