symbolic_ref.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
| 1 | """muse symbolic-ref — read or write HEAD's symbolic reference. |
| 2 | |
| 3 | In Muse, HEAD is a symbolic reference that points to a branch (normal mode) |
| 4 | or directly to a commit (detached HEAD state). This command reads which |
| 5 | branch HEAD currently tracks, handles detached HEAD gracefully, or — with |
| 6 | ``--set`` — updates HEAD to point to a different branch. |
| 7 | |
| 8 | Read mode output (JSON, default — normal branch HEAD):: |
| 9 | |
| 10 | { |
| 11 | "ref": "HEAD", |
| 12 | "symbolic_target": "refs/heads/main", |
| 13 | "branch": "main", |
| 14 | "commit_id": "<sha256>", |
| 15 | "detached": false, |
| 16 | "duration_ms": 1.234, |
| 17 | "exit_code": 0 |
| 18 | } |
| 19 | |
| 20 | Read mode output (JSON — detached HEAD):: |
| 21 | |
| 22 | { |
| 23 | "ref": "HEAD", |
| 24 | "symbolic_target": null, |
| 25 | "branch": null, |
| 26 | "commit_id": "<sha256>", |
| 27 | "detached": true, |
| 28 | "duration_ms": 0.8, |
| 29 | "exit_code": 0 |
| 30 | } |
| 31 | |
| 32 | When a branch has no commits yet, ``commit_id`` is ``null``. |
| 33 | |
| 34 | Write mode (``--set <branch>``):: |
| 35 | |
| 36 | muse symbolic-ref HEAD --set main |
| 37 | |
| 38 | Output after a successful write:: |
| 39 | |
| 40 | { |
| 41 | "ref": "HEAD", |
| 42 | "symbolic_target": "refs/heads/main", |
| 43 | "branch": "main", |
| 44 | "commit_id": "<sha256> | null", |
| 45 | "detached": false, |
| 46 | "duration_ms": 2.1, |
| 47 | "exit_code": 0 |
| 48 | } |
| 49 | |
| 50 | JSON error output (always to stdout so agents can parse failures):: |
| 51 | |
| 52 | { |
| 53 | "error": "<error_key>", |
| 54 | "message": "<human-readable description>", |
| 55 | "duration_ms": 0.3, |
| 56 | "exit_code": 1 |
| 57 | } |
| 58 | |
| 59 | Text output (``--format text``, read mode):: |
| 60 | |
| 61 | refs/heads/main |
| 62 | |
| 63 | With ``--short``:: |
| 64 | |
| 65 | main |
| 66 | |
| 67 | Output contract |
| 68 | --------------- |
| 69 | |
| 70 | - Exit 0: ref read or updated successfully. |
| 71 | - Exit 1: ``--set`` target branch does not exist (unless ``--create-branch``); |
| 72 | bad ``--format``; unsupported ref name; invalid branch name. |
| 73 | - Exit 3: I/O error reading or writing HEAD. |
| 74 | |
| 75 | Agent use |
| 76 | --------- |
| 77 | |
| 78 | Read the current branch from any pipeline step:: |
| 79 | |
| 80 | muse symbolic-ref HEAD --short --format text |
| 81 | # → main |
| 82 | |
| 83 | Check whether HEAD is detached:: |
| 84 | |
| 85 | muse symbolic-ref HEAD --json | python3 -c "import sys,json; print(json.load(sys.stdin)['detached'])" |
| 86 | |
| 87 | Point HEAD at a new empty branch (orphan-style):: |
| 88 | |
| 89 | muse symbolic-ref HEAD --set feat/new --create-branch --json |
| 90 | |
| 91 | Switch HEAD to an existing branch:: |
| 92 | |
| 93 | muse symbolic-ref HEAD --set main --json |
| 94 | |
| 95 | Agents should always pass ``--json`` and parse ``exit_code`` from the |
| 96 | response rather than relying on the process exit code alone. Every |
| 97 | response — success and error alike — includes ``duration_ms`` (float, |
| 98 | milliseconds) for latency monitoring. |
| 99 | """ |
| 100 | |
| 101 | import argparse |
| 102 | import json |
| 103 | import logging |
| 104 | import pathlib |
| 105 | import sys |
| 106 | from typing import TypedDict |
| 107 | |
| 108 | from muse.core.paths import ref_path as _ref_path |
| 109 | from muse.core.errors import ExitCode |
| 110 | from muse.core.repo import require_repo |
| 111 | from muse.core.refs import ( |
| 112 | get_head_commit_id, |
| 113 | read_head, |
| 114 | write_head_branch, |
| 115 | ) |
| 116 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 117 | from muse.core.validation import sanitize_display, validate_branch_name |
| 118 | from muse.core.timing import start_timer |
| 119 | |
| 120 | logger = logging.getLogger(__name__) |
| 121 | |
| 122 | type _RefData = dict[str, str | bool | None] |
| 123 | |
| 124 | class _SymbolicRefResult(EnvelopeJson): |
| 125 | ref: str |
| 126 | symbolic_target: str | None |
| 127 | branch: str | None |
| 128 | commit_id: str | None |
| 129 | detached: bool |
| 130 | |
| 131 | def _read_symbolic_ref(root: pathlib.Path) -> _RefData: |
| 132 | """Return the current HEAD symbolic-ref data (no envelope fields). |
| 133 | |
| 134 | Handles both the normal (branch) and detached (commit) HEAD states |
| 135 | without raising — detached HEAD is represented as a structured result |
| 136 | with ``detached=True`` and ``branch=None``. |
| 137 | |
| 138 | Uses :func:`muse.core.store.read_head` directly so both states are |
| 139 | covered, rather than :func:`read_current_branch` which raises on |
| 140 | detached HEAD. |
| 141 | |
| 142 | The returned dict does **not** include envelope fields — those are |
| 143 | added by :func:`run` via ``make_envelope`` once overall timing is known. |
| 144 | """ |
| 145 | state = read_head(root) |
| 146 | if state["kind"] == "branch": |
| 147 | branch = state["branch"] |
| 148 | commit_id = get_head_commit_id(root, branch) |
| 149 | return { |
| 150 | "ref": "HEAD", |
| 151 | "symbolic_target": f"refs/heads/{branch}", |
| 152 | "branch": branch, |
| 153 | "commit_id": commit_id, |
| 154 | "detached": False, |
| 155 | } |
| 156 | # Detached HEAD — HEAD points directly to a commit. |
| 157 | return { |
| 158 | "ref": "HEAD", |
| 159 | "symbolic_target": None, |
| 160 | "branch": None, |
| 161 | "commit_id": state["commit_id"], |
| 162 | "detached": True, |
| 163 | } |
| 164 | |
| 165 | def _branch_exists(root: pathlib.Path, branch: str) -> bool: |
| 166 | """Return True if the branch ref file exists and is not a symlink. |
| 167 | |
| 168 | Symlinks are rejected for consistency with the object store and ref |
| 169 | listing — a symlink at ``.muse/refs/heads/<branch>`` could point |
| 170 | anywhere outside the repository. |
| 171 | """ |
| 172 | rp = _ref_path(root, branch) |
| 173 | return rp.is_file() and not rp.is_symlink() |
| 174 | |
| 175 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 176 | """Register the ``muse symbolic-ref`` subcommand and its flags.""" |
| 177 | parser = subparsers.add_parser( |
| 178 | "symbolic-ref", |
| 179 | help="Read or write HEAD's symbolic branch reference.", |
| 180 | description=__doc__, |
| 181 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 182 | ) |
| 183 | parser.add_argument( |
| 184 | "ref", |
| 185 | nargs="?", |
| 186 | default="HEAD", |
| 187 | help=( |
| 188 | "The symbolic ref to query or update. " |
| 189 | "Currently only HEAD is supported." |
| 190 | ), |
| 191 | ) |
| 192 | parser.add_argument( |
| 193 | "--set", "-s", |
| 194 | default=None, |
| 195 | dest="set_branch", |
| 196 | metavar="BRANCH", |
| 197 | help="Branch name to point HEAD at (write mode).", |
| 198 | ) |
| 199 | parser.add_argument( |
| 200 | "--create-branch", |
| 201 | action="store_true", |
| 202 | dest="create_branch", |
| 203 | help=( |
| 204 | "With ``--set``, create the branch pointer even if the branch has " |
| 205 | "no commits yet (orphan-style). Without this flag, ``--set`` " |
| 206 | "requires the branch to already exist." |
| 207 | ), |
| 208 | ) |
| 209 | parser.add_argument( |
| 210 | "--json", "-j", |
| 211 | action="store_true", |
| 212 | dest="json_out", |
| 213 | help="Emit machine-readable JSON on stdout.", |
| 214 | ) |
| 215 | parser.add_argument( |
| 216 | "--short", "-S", |
| 217 | action="store_true", |
| 218 | help=( |
| 219 | "In text mode, emit only the branch name rather than the full " |
| 220 | "``refs/heads/<branch>`` path. Ignored in detached HEAD state." |
| 221 | ), |
| 222 | ) |
| 223 | parser.set_defaults(func=run, json_out=False) |
| 224 | |
| 225 | def run(args: argparse.Namespace) -> None: |
| 226 | """Read or write HEAD's symbolic reference. |
| 227 | |
| 228 | With no ``--set`` flag, reads which branch HEAD points to and the commit |
| 229 | ID at that tip. Detached HEAD is a structured result (``detached=true``), |
| 230 | not an error. With ``--set <branch>``, updates HEAD to point to that |
| 231 | branch; use ``--create-branch`` for orphan mode. |
| 232 | |
| 233 | Agent quickstart:: |
| 234 | |
| 235 | muse symbolic-ref HEAD --json |
| 236 | muse symbolic-ref HEAD --set main --json |
| 237 | muse symbolic-ref HEAD --set feat/x --create-branch --json |
| 238 | muse symbolic-ref HEAD --short --format text |
| 239 | |
| 240 | JSON fields:: |
| 241 | |
| 242 | ref Always ``"HEAD"``. |
| 243 | symbolic_target Full ref path e.g. ``"refs/heads/main"``; ``null`` when detached. |
| 244 | branch Branch name; ``null`` when detached. |
| 245 | commit_id SHA-256 commit ID at the branch tip; ``null`` when no commits yet. |
| 246 | detached ``true`` when HEAD points directly to a commit. |
| 247 | muse_version Muse release that produced this output. |
| 248 | schema Envelope schema version (int). |
| 249 | exit_code ``0`` on success, ``1`` on user error, ``3`` on I/O error. |
| 250 | duration_ms Wall-clock milliseconds for the command. |
| 251 | timestamp ISO-8601 UTC timestamp of command completion. |
| 252 | warnings List of non-fatal advisory messages. |
| 253 | |
| 254 | Exit codes:: |
| 255 | |
| 256 | 0 Ref read or updated successfully. |
| 257 | 1 User error (bad format, unsupported ref, branch not found, invalid name). |
| 258 | 3 I/O error reading or writing HEAD. |
| 259 | """ |
| 260 | elapsed = start_timer() |
| 261 | |
| 262 | json_out: bool = args.json_out |
| 263 | ref: str = args.ref |
| 264 | set_branch: str | None = args.set_branch |
| 265 | create_branch: bool = args.create_branch |
| 266 | short: bool = args.short |
| 267 | |
| 268 | def _emit_error( |
| 269 | msg: str, |
| 270 | code: int, |
| 271 | error_key: str = "error", |
| 272 | **extra: str, |
| 273 | ) -> None: |
| 274 | if json_out: |
| 275 | payload = { |
| 276 | **make_envelope(elapsed, exit_code=code), |
| 277 | "error": error_key, |
| 278 | "message": msg, |
| 279 | } |
| 280 | payload.update(extra) |
| 281 | print(json.dumps(payload)) |
| 282 | else: |
| 283 | print(f"❌ {msg}", file=sys.stderr) |
| 284 | raise SystemExit(code) |
| 285 | |
| 286 | ref_upper = ref.upper() |
| 287 | if ref_upper != "HEAD": |
| 288 | _emit_error( |
| 289 | f"Unsupported ref {sanitize_display(ref)!r}. Only HEAD is supported.", |
| 290 | ExitCode.USER_ERROR, |
| 291 | "unsupported_ref", |
| 292 | ) |
| 293 | |
| 294 | root = require_repo() |
| 295 | |
| 296 | # ── Write mode ──────────────────────────────────────────────────────────── |
| 297 | if set_branch is not None: |
| 298 | try: |
| 299 | validate_branch_name(set_branch) |
| 300 | except ValueError as exc: |
| 301 | _emit_error( |
| 302 | sanitize_display(str(exc)), |
| 303 | ExitCode.USER_ERROR, |
| 304 | "invalid_branch_name", |
| 305 | ) |
| 306 | |
| 307 | if not create_branch and not _branch_exists(root, set_branch): |
| 308 | _emit_error( |
| 309 | f"Branch {sanitize_display(set_branch)!r} does not exist. " |
| 310 | "Use --create-branch to point HEAD at a new empty branch.", |
| 311 | ExitCode.USER_ERROR, |
| 312 | "branch_not_found", |
| 313 | ) |
| 314 | |
| 315 | try: |
| 316 | write_head_branch(root, set_branch) |
| 317 | except OSError as exc: |
| 318 | logger.debug("symbolic-ref write error: %s", exc) |
| 319 | _emit_error( |
| 320 | sanitize_display(str(exc)), |
| 321 | ExitCode.INTERNAL_ERROR, |
| 322 | "io_error", |
| 323 | ) |
| 324 | |
| 325 | commit_id = get_head_commit_id(root, set_branch) |
| 326 | if not json_out: |
| 327 | if short: |
| 328 | print(sanitize_display(set_branch)) |
| 329 | else: |
| 330 | print(sanitize_display(f"refs/heads/{set_branch}")) |
| 331 | return |
| 332 | print(json.dumps(_SymbolicRefResult( |
| 333 | **make_envelope(elapsed), |
| 334 | ref="HEAD", |
| 335 | symbolic_target=f"refs/heads/{set_branch}", |
| 336 | branch=set_branch, |
| 337 | commit_id=commit_id, |
| 338 | detached=False, |
| 339 | ))) |
| 340 | return |
| 341 | |
| 342 | # ── Read mode ───────────────────────────────────────────────────────────── |
| 343 | try: |
| 344 | result = _read_symbolic_ref(root) |
| 345 | except (OSError, ValueError) as exc: |
| 346 | logger.debug("symbolic-ref read error: %s", exc) |
| 347 | _emit_error( |
| 348 | sanitize_display(str(exc)), |
| 349 | ExitCode.INTERNAL_ERROR, |
| 350 | "io_error", |
| 351 | ) |
| 352 | |
| 353 | if not json_out: |
| 354 | if result["detached"]: |
| 355 | commit = result["commit_id"] or "(no commit)" |
| 356 | print(sanitize_display(f"(HEAD detached at {commit})")) |
| 357 | elif short: |
| 358 | print(sanitize_display(str(result["branch"] or ""))) |
| 359 | else: |
| 360 | print(sanitize_display(str(result["symbolic_target"] or ""))) |
| 361 | return |
| 362 | |
| 363 | print(json.dumps(_SymbolicRefResult( |
| 364 | **make_envelope(elapsed), |
| 365 | ref=str(result["ref"]), |
| 366 | symbolic_target=result["symbolic_target"], # type: ignore[arg-type] |
| 367 | branch=result["branch"], # type: ignore[arg-type] |
| 368 | commit_id=result["commit_id"], # type: ignore[arg-type] |
| 369 | detached=bool(result["detached"]), |
| 370 | ))) |
File History
7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
1 day ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
30 days ago