ls_remote.py
python
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