gabriel / muse public
ls_remote.py python
262 lines 8.7 KB
Raw
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 5 days ago
1 """muse ls-remote — list references on a remote repository.
2
3 Contacts the remote and prints every branch and its current commit ID without
4 modifying any local state. Useful for scripting, agent coordination, and
5 pre-flight checks before push/pull.
6
7 Output (JSON, default)::
8
9 {
10 "status": "ok",
11 "error": "",
12 "repo_id": "<sha256:...>",
13 "domain": "midi",
14 "default_branch": "main",
15 "branches": {"main": "sha256:<commit_id>", "feat/x": "sha256:<commit_id>"},
16 "remote": "origin",
17 "url": "https://musehub.ai/org/repo",
18 "duration_ms": 4.1,
19 "exit_code": 0
20 }
21
22 All keys are always present so agents can read them without ``dict.get``
23 guards. ``"status"`` is always ``"ok"`` on success.
24
25 ``"remote"`` is the configured remote name used to resolve the URL (e.g.
26 ``"origin"``). It is ``null`` when the caller passed a full URL directly
27 instead of a remote name — no config lookup was performed.
28
29 ``"url"`` is the resolved URL that was actually contacted. Always present.
30
31 Branch OIDs are always ``sha256:``-prefixed regardless of what the remote
32 returns — bare hex is normalized to the canonical form.
33
34 Output format (``--format text`` — one line per branch, ``*`` marks the default branch)::
35
36 sha256:<commit_id>\\t<branch>
37 sha256:<commit_id>\\t<branch> *
38
39 JSON error schema (exit non-zero)::
40
41 {
42 "status": "error",
43 "error": "<human-readable message>",
44 "exit_code": 1
45 }
46
47 When ``--json`` is active all errors go to stdout as JSON — no prose on
48 stderr. Agents should parse stdout and check ``status``.
49
50 Agent use
51 ---------
52
53 Pass a remote name (configured via ``muse remote add``) or a full URL::
54
55 muse ls-remote origin --json
56 muse ls-remote https://musehub.ai/org/repo --json
57
58 Output contract
59 ---------------
60
61 - Exit 0: remote contacted, refs printed.
62 - Exit 1: remote not configured, URL looks invalid, or unknown ``--format``.
63 - Exit 3: transport error (network unreachable, HTTP error).
64 """
65
66 import argparse
67 import json
68 import logging
69 import pathlib
70 import sys
71 from typing import TypedDict
72
73 from muse.cli.config import get_signing_identity, get_remote
74 from muse.core.types import long_id
75 from muse.core.envelope import EnvelopeJson, make_envelope
76 from muse.core.errors import ExitCode
77 from muse.core.repo import find_repo_root
78 from muse.core.transport import HttpTransport, TransportError
79 from muse.core.validation import sanitize_display
80 from muse.core.timing import start_timer
81
82 logger = logging.getLogger(__name__)
83
84 _HEX_CHARS = frozenset("0123456789abcdef")
85
86 type _BranchHeads = dict[str, str]
87
88 class _LsRemoteJson(EnvelopeJson):
89 """Stable JSON envelope for ``muse ls-remote --json``.
90
91 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
92
93 All keys are always present so agents can read them without ``dict.get``
94 guards. ``status`` is ``"ok"`` on success.
95 """
96 status: str # "ok"
97 error: str # always "" on success
98 repo_id: str
99 domain: str
100 default_branch: str
101 branches: _BranchHeads
102 remote: str | None # remote name used; null when URL passed directly
103 url: str # resolved URL that was contacted
104
105 class _LsRemoteErrorJson(EnvelopeJson):
106 """Error payload for ``muse ls-remote --json`` on usage or transport errors."""
107 status: str # "error"
108 error: str
109
110 def _normalize_oid(oid: str) -> str:
111 """Ensure an OID carries the canonical ``sha256:`` prefix.
112
113 Defense in depth: remotes *should* return prefixed OIDs but may not.
114 Bare 64-character hex strings are normalized. Anything else is returned
115 as-is — the caller is responsible for validating the result further.
116 """
117 if oid.startswith("sha256:"):
118 return oid
119 if len(oid) == 64 and all(c in _HEX_CHARS for c in oid.lower()):
120 return long_id(oid.lower())
121 return oid
122
123 def _emit_error(json_out: bool, msg: str, code: ExitCode, elapsed: float) -> None:
124 """Print an error and raise SystemExit. Never returns.
125
126 In ``--json`` mode the error goes to stdout as a JSON payload so machine
127 consumers always get parseable output. In text mode it goes to stderr.
128 """
129 if json_out:
130 print(json.dumps(_LsRemoteErrorJson(
131 **make_envelope(elapsed, exit_code=int(code)),
132 status="error",
133 error=msg,
134 )))
135 else:
136 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
137 raise SystemExit(code)
138
139 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
140 """Register the ls-remote subcommand."""
141 parser = subparsers.add_parser(
142 "ls-remote",
143 help="List branch heads on a remote without modifying local state.",
144 description=__doc__,
145 formatter_class=argparse.RawDescriptionHelpFormatter,
146 )
147 parser.add_argument(
148 "remote_or_url",
149 nargs="?",
150 default="origin",
151 help="Remote name (e.g. 'origin') or a full URL. Defaults to 'origin'.",
152 )
153 parser.add_argument(
154 "--json", "-j",
155 action="store_true",
156 dest="json_out",
157 help="Emit machine-readable JSON.",
158 )
159 parser.set_defaults(func=run)
160
161 def run(args: argparse.Namespace) -> None:
162 """List branches and commit IDs on a remote.
163
164 Contacts the remote and prints each branch HEAD without altering any local
165 state. Pass a remote name (configured via ``muse remote add``) or a full
166 URL. ``remote`` in the JSON output is ``null`` when a full URL was passed
167 directly — no config lookup occurred.
168
169 Agent quickstart
170 ----------------
171 ::
172
173 muse ls-remote --json
174 muse ls-remote origin --json
175 muse ls-remote local --json
176 muse ls-remote https://musehub.ai/gabriel/muse --json
177
178 JSON fields
179 -----------
180 status ``"ok"`` on success.
181 repo_id Remote repository identifier.
182 domain Domain of the remote repo (``"code"``, ``"audio"``, …).
183 default_branch The remote's default branch name.
184 branches Map of ``branch_name → sha256:<commit_id>``.
185 remote Remote name used, or ``null`` when a full URL was passed.
186 url Resolved URL that was contacted.
187
188 Exit codes
189 ----------
190 0 Success.
191 1 Remote not configured and argument is not a URL; unknown ``--format``.
192 3 Transport error — network unreachable or HTTP error.
193 """
194 elapsed = start_timer()
195
196 json_out: bool = args.json_out
197 remote_or_url: str = args.remote_or_url
198
199 root = find_repo_root(pathlib.Path.cwd())
200
201 # Track whether we resolved a named remote (remote_name) vs a raw URL.
202 remote_name: str | None = None
203 url: str | None = None
204
205 if root is not None:
206 resolved = get_remote(remote_or_url, root)
207 if resolved is not None:
208 remote_name = remote_or_url
209 url = resolved
210
211 if url is None:
212 if remote_or_url.startswith("http://") or remote_or_url.startswith("https://"):
213 url = remote_or_url
214 # remote_name stays None — caller passed URL directly
215 else:
216 _emit_error(
217 json_out,
218 f"'{remote_or_url}' is not a configured remote and does not look like a URL. "
219 f"Configure it with: muse remote add <name> <url>",
220 ExitCode.USER_ERROR,
221 elapsed,
222 )
223
224 # Resolve signing identity against the actual target URL so that named
225 # remotes pointing to staging (or any host other than the repo's default
226 # hub) use the correct registered key rather than falling back to the
227 # local-hub key.
228 token = get_signing_identity(root, remote_url=url)
229
230 transport = HttpTransport()
231 try:
232 info = transport.fetch_remote_info(url, token)
233 except TransportError as exc:
234 _emit_error(json_out, f"Cannot reach remote: {exc}", ExitCode.INTERNAL_ERROR, elapsed)
235
236 # Normalize all branch OIDs to sha256: prefix — defense in depth.
237 branches = {
238 branch: _normalize_oid(oid)
239 for branch, oid in info["branch_heads"].items()
240 }
241
242 if json_out:
243 print(json.dumps(_LsRemoteJson(
244 **make_envelope(elapsed),
245 status="ok",
246 error="",
247 repo_id=info["repo_id"],
248 domain=info["domain"],
249 default_branch=info["default_branch"],
250 branches=branches,
251 remote=remote_name,
252 url=url,
253 )))
254 return
255
256 if not branches:
257 print("(no branches)")
258 return
259
260 for branch, commit_id in sorted(branches.items()):
261 marker = " *" if branch == info["default_branch"] else ""
262 print(f"{sanitize_display(commit_id)}\t{sanitize_display(branch)}{marker}")
File History 2 commits
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 5 days ago
sha256:f8e686793bb93114c2923d0d294162d13b4e6f4d57ae0f6cbc1e0d493e80f965 fix: ls-remote signing identity uses resolved remote URL Sonnet 4.6 patch 5 days ago