gabriel / muse public
symbolic_ref.py python
370 lines 11.8 KB
Raw
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