symbolic_ref.py python
310 lines 9.2 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
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)))
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago