symbolic_ref.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """muse plumbing 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 | } |
| 17 | |
| 18 | Read mode output (JSON β detached HEAD):: |
| 19 | |
| 20 | { |
| 21 | "ref": "HEAD", |
| 22 | "symbolic_target": null, |
| 23 | "branch": null, |
| 24 | "commit_id": "<sha256>", |
| 25 | "detached": true |
| 26 | } |
| 27 | |
| 28 | When a branch has no commits yet, ``commit_id`` is ``null``. |
| 29 | |
| 30 | Write mode (``--set <branch>``):: |
| 31 | |
| 32 | muse plumbing symbolic-ref HEAD --set main |
| 33 | |
| 34 | Output after a successful write:: |
| 35 | |
| 36 | { |
| 37 | "ref": "HEAD", |
| 38 | "symbolic_target": "refs/heads/main", |
| 39 | "branch": "main", |
| 40 | "commit_id": "<sha256> | null", |
| 41 | "detached": false |
| 42 | } |
| 43 | |
| 44 | Text output (``--format text``, read mode):: |
| 45 | |
| 46 | refs/heads/main |
| 47 | |
| 48 | With ``--short``:: |
| 49 | |
| 50 | main |
| 51 | |
| 52 | Plumbing contract |
| 53 | ----------------- |
| 54 | |
| 55 | - Exit 0: ref read or updated successfully. |
| 56 | - Exit 1: ``--set`` target branch does not exist (unless ``--create-branch``); |
| 57 | bad ``--format``; unsupported ref name. |
| 58 | - Exit 3: I/O error reading or writing HEAD. |
| 59 | |
| 60 | Agent use |
| 61 | --------- |
| 62 | |
| 63 | Read the current branch from any pipeline step:: |
| 64 | |
| 65 | muse plumbing symbolic-ref HEAD --short --format text |
| 66 | # β main |
| 67 | |
| 68 | Check whether HEAD is detached:: |
| 69 | |
| 70 | muse plumbing symbolic-ref HEAD --json | python3 -c "import sys,json; print(json.load(sys.stdin)['detached'])" |
| 71 | |
| 72 | Point HEAD at a new empty branch (orphan-style):: |
| 73 | |
| 74 | muse plumbing symbolic-ref HEAD --set feat/new --create-branch --json |
| 75 | |
| 76 | Switch HEAD to an existing branch:: |
| 77 | |
| 78 | muse plumbing symbolic-ref HEAD --set main --json |
| 79 | """ |
| 80 | |
| 81 | from __future__ import annotations |
| 82 | |
| 83 | import argparse |
| 84 | import json |
| 85 | import logging |
| 86 | import pathlib |
| 87 | import sys |
| 88 | from typing import TypedDict |
| 89 | |
| 90 | from muse.core.errors import ExitCode |
| 91 | from muse.core.repo import require_repo |
| 92 | from muse.core.store import ( |
| 93 | get_head_commit_id, |
| 94 | read_head, |
| 95 | write_head_branch, |
| 96 | ) |
| 97 | from muse.core.validation import sanitize_display, validate_branch_name |
| 98 | |
| 99 | logger = logging.getLogger(__name__) |
| 100 | |
| 101 | _FORMAT_CHOICES = ("json", "text") |
| 102 | |
| 103 | |
| 104 | class _SymbolicRefResult(TypedDict): |
| 105 | ref: str |
| 106 | symbolic_target: str | None |
| 107 | branch: str | None |
| 108 | commit_id: str | None |
| 109 | detached: bool |
| 110 | |
| 111 | |
| 112 | def _read_symbolic_ref(root: pathlib.Path) -> _SymbolicRefResult: |
| 113 | """Return the current HEAD symbolic-ref data. |
| 114 | |
| 115 | Handles both the normal (branch) and detached (commit) HEAD states |
| 116 | without raising β detached HEAD is represented as a structured result |
| 117 | with ``detached=True`` and ``branch=None``. |
| 118 | |
| 119 | Uses :func:`muse.core.store.read_head` directly so both states are |
| 120 | covered, rather than :func:`read_current_branch` which raises on |
| 121 | detached HEAD. |
| 122 | """ |
| 123 | state = read_head(root) |
| 124 | if state["kind"] == "branch": |
| 125 | branch = state["branch"] |
| 126 | commit_id = get_head_commit_id(root, branch) |
| 127 | return { |
| 128 | "ref": "HEAD", |
| 129 | "symbolic_target": f"refs/heads/{branch}", |
| 130 | "branch": branch, |
| 131 | "commit_id": commit_id, |
| 132 | "detached": False, |
| 133 | } |
| 134 | # Detached HEAD β HEAD points directly to a commit. |
| 135 | return { |
| 136 | "ref": "HEAD", |
| 137 | "symbolic_target": None, |
| 138 | "branch": None, |
| 139 | "commit_id": state["commit_id"], |
| 140 | "detached": True, |
| 141 | } |
| 142 | |
| 143 | |
| 144 | def _branch_exists(root: pathlib.Path, branch: str) -> bool: |
| 145 | """Return True if the branch ref file exists and is not a symlink. |
| 146 | |
| 147 | Symlinks are rejected for consistency with the object store and ref |
| 148 | listing β a symlink at ``.muse/refs/heads/<branch>`` could point |
| 149 | anywhere outside the repository. |
| 150 | """ |
| 151 | ref_path = root / ".muse" / "refs" / "heads" / branch |
| 152 | return ref_path.is_file() and not ref_path.is_symlink() |
| 153 | |
| 154 | |
| 155 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 156 | """Register the symbolic-ref subcommand.""" |
| 157 | parser = subparsers.add_parser( |
| 158 | "symbolic-ref", |
| 159 | help="Read or write HEAD's symbolic branch reference.", |
| 160 | description=__doc__, |
| 161 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 162 | ) |
| 163 | parser.add_argument( |
| 164 | "ref", |
| 165 | nargs="?", |
| 166 | default="HEAD", |
| 167 | help=( |
| 168 | "The symbolic ref to query or update. " |
| 169 | "Currently only HEAD is supported." |
| 170 | ), |
| 171 | ) |
| 172 | parser.add_argument( |
| 173 | "--set", "-s", |
| 174 | default=None, |
| 175 | dest="set_branch", |
| 176 | metavar="BRANCH", |
| 177 | help="Branch name to point HEAD at (write mode).", |
| 178 | ) |
| 179 | parser.add_argument( |
| 180 | "--create-branch", |
| 181 | action="store_true", |
| 182 | dest="create_branch", |
| 183 | help=( |
| 184 | "With ``--set``, create the branch pointer even if the branch has " |
| 185 | "no commits yet (orphan-style). Without this flag, ``--set`` " |
| 186 | "requires the branch to already exist." |
| 187 | ), |
| 188 | ) |
| 189 | parser.add_argument( |
| 190 | "--format", "-f", |
| 191 | dest="fmt", |
| 192 | default="json", |
| 193 | metavar="FORMAT", |
| 194 | help="Output format: json or text. (default: json)", |
| 195 | ) |
| 196 | parser.add_argument( |
| 197 | "--json", action="store_const", const="json", dest="fmt", |
| 198 | help="Shorthand for --format json.", |
| 199 | ) |
| 200 | parser.add_argument( |
| 201 | "--short", "-S", |
| 202 | action="store_true", |
| 203 | help=( |
| 204 | "In text mode, emit only the branch name rather than the full " |
| 205 | "``refs/heads/<branch>`` path. Ignored in detached HEAD state." |
| 206 | ), |
| 207 | ) |
| 208 | parser.set_defaults(func=run) |
| 209 | |
| 210 | |
| 211 | def run(args: argparse.Namespace) -> None: |
| 212 | """Read or write HEAD's symbolic reference. |
| 213 | |
| 214 | With no ``--set`` flag, reads the current branch HEAD points to and |
| 215 | the commit ID at that branch tip. Detached HEAD state is represented |
| 216 | as a structured result rather than an error. |
| 217 | |
| 218 | With ``--set <branch>``, updates HEAD to point to *branch*. By default |
| 219 | the branch must already exist; use ``--create-branch`` to point HEAD at |
| 220 | a branch with no commits yet (orphan mode used by ``muse init``). |
| 221 | """ |
| 222 | fmt: str = args.fmt |
| 223 | ref: str = args.ref |
| 224 | set_branch: str | None = args.set_branch |
| 225 | create_branch: bool = args.create_branch |
| 226 | short: bool = args.short |
| 227 | |
| 228 | if fmt not in _FORMAT_CHOICES: |
| 229 | print( |
| 230 | json.dumps( |
| 231 | {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} |
| 232 | ), |
| 233 | file=sys.stderr, |
| 234 | ) |
| 235 | raise SystemExit(ExitCode.USER_ERROR) |
| 236 | |
| 237 | ref_upper = ref.upper() |
| 238 | if ref_upper != "HEAD": |
| 239 | print( |
| 240 | json.dumps({"error": f"Unsupported ref {ref!r}. Only HEAD is supported."}), |
| 241 | file=sys.stderr, |
| 242 | ) |
| 243 | raise SystemExit(ExitCode.USER_ERROR) |
| 244 | |
| 245 | root = require_repo() |
| 246 | |
| 247 | # Write mode |
| 248 | if set_branch is not None: |
| 249 | try: |
| 250 | validate_branch_name(set_branch) |
| 251 | except ValueError as exc: |
| 252 | print(json.dumps({"error": str(exc)}), file=sys.stderr) |
| 253 | raise SystemExit(ExitCode.USER_ERROR) |
| 254 | |
| 255 | if not create_branch and not _branch_exists(root, set_branch): |
| 256 | print( |
| 257 | json.dumps({ |
| 258 | "error": ( |
| 259 | f"Branch {set_branch!r} does not exist. " |
| 260 | "Use --create-branch to point HEAD at a new empty branch." |
| 261 | ) |
| 262 | }), |
| 263 | file=sys.stderr, |
| 264 | ) |
| 265 | raise SystemExit(ExitCode.USER_ERROR) |
| 266 | |
| 267 | try: |
| 268 | write_head_branch(root, set_branch) |
| 269 | except OSError as exc: |
| 270 | logger.debug("symbolic-ref write error: %s", exc) |
| 271 | print(json.dumps({"error": str(exc)}), file=sys.stderr) |
| 272 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 273 | |
| 274 | commit_id = get_head_commit_id(root, set_branch) |
| 275 | result: _SymbolicRefResult = { |
| 276 | "ref": "HEAD", |
| 277 | "symbolic_target": f"refs/heads/{set_branch}", |
| 278 | "branch": set_branch, |
| 279 | "commit_id": commit_id, |
| 280 | "detached": False, |
| 281 | } |
| 282 | if fmt == "text": |
| 283 | if short: |
| 284 | print(sanitize_display(set_branch)) |
| 285 | else: |
| 286 | print(sanitize_display(f"refs/heads/{set_branch}")) |
| 287 | return |
| 288 | print(json.dumps(dict(result))) |
| 289 | return |
| 290 | |
| 291 | # Read mode |
| 292 | try: |
| 293 | result = _read_symbolic_ref(root) |
| 294 | except (OSError, ValueError) as exc: |
| 295 | logger.debug("symbolic-ref read error: %s", exc) |
| 296 | print(json.dumps({"error": str(exc)}), file=sys.stderr) |
| 297 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 298 | |
| 299 | if fmt == "text": |
| 300 | if result["detached"]: |
| 301 | # Detached HEAD has no branch name; emit the commit ID. |
| 302 | commit = result["commit_id"] or "(no commit)" |
| 303 | print(sanitize_display(f"(HEAD detached at {commit[:12]})")) |
| 304 | elif short: |
| 305 | print(sanitize_display(result["branch"] or "")) |
| 306 | else: |
| 307 | print(sanitize_display(result["symbolic_target"] or "")) |
| 308 | return |
| 309 | |
| 310 | print(json.dumps(dict(result))) |