remote.py
python
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed
chore: pivot to nightly channel — bump version to 0.2.0.dev…
Sonnet 5
patch
13 days ago
| 1 | """muse remote — manage remote repository connections. |
| 2 | |
| 3 | Subcommands |
| 4 | ----------- |
| 5 | |
| 6 | muse remote [-v] [--json] List configured remotes |
| 7 | muse remote add <name> <url> [--json] Register a new remote |
| 8 | muse remote get-url <name> [--json] Print a remote's URL |
| 9 | muse remote remove <name> [--json] Remove a remote and its tracking refs |
| 10 | muse remote rename <old> <new> [--json] Rename a remote |
| 11 | muse remote set-url <name> <url> [--json] Update a remote's URL |
| 12 | muse remote status <name> [--json] Check reachability and last-known refs |
| 13 | |
| 14 | All remote URLs and tracking data are stored in ``.muse/config.toml`` and |
| 15 | ``.muse/remotes/<name>/<branch>`` — no network calls are made except for |
| 16 | ``muse remote status`` which pings the remote's health endpoint. |
| 17 | |
| 18 | JSON schema (subcommand-specific) |
| 19 | ---------------------------------- |
| 20 | |
| 21 | ``muse remote [--json]``:: |
| 22 | |
| 23 | { |
| 24 | "remotes": [{"name": "<name>", "url": "<url>", |
| 25 | "tracking": "<name>/<branch>", "head": "<sha8>"}], |
| 26 | "duration_ms": 3, |
| 27 | "exit_code": 0 |
| 28 | } |
| 29 | |
| 30 | ``muse remote add|remove|rename|set-url [--json]``:: |
| 31 | |
| 32 | {"status": "ok", "name": "<name>", "url": "<url>|null", |
| 33 | "old_name": "<name>|null", "new_name": "<name>|null", |
| 34 | "duration_ms": 2, "exit_code": 0} |
| 35 | |
| 36 | ``muse remote get-url [--json]``:: |
| 37 | |
| 38 | {"name": "<name>", "url": "<url>", "duration_ms": 1, "exit_code": 0} |
| 39 | |
| 40 | ``muse remote status [--json]``:: |
| 41 | |
| 42 | {"remote": "<name>", "url": "<url>", "server_root": "<url>", |
| 43 | "reachable": true|false, "http_status": <N>|null, |
| 44 | "message": "<msg>", "tracked_refs": {"<branch>": "<sha8>"}, |
| 45 | "duration_ms": 42, "exit_code": 0} |
| 46 | |
| 47 | All error responses also carry ``duration_ms`` and ``exit_code`` so agents |
| 48 | can measure latency and confirm outcomes without parsing exit codes separately. |
| 49 | When ``--json`` is active, error diagnostics are embedded in the JSON |
| 50 | ``message`` field only — no additional text is written to stderr. |
| 51 | |
| 52 | Exit codes |
| 53 | ---------- |
| 54 | |
| 55 | 0 — success |
| 56 | 1 — user error (unknown remote, duplicate remote, invalid URL scheme, invalid name) |
| 57 | 2 — not inside a Muse repository |
| 58 | 5 — remote unreachable (status subcommand) |
| 59 | """ |
| 60 | |
| 61 | import argparse |
| 62 | import json |
| 63 | import logging |
| 64 | import sys |
| 65 | import urllib.error |
| 66 | import urllib.request |
| 67 | from typing import TypedDict |
| 68 | from urllib.parse import urlparse |
| 69 | import pathlib |
| 70 | |
| 71 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 72 | from muse.core.refs import read_ref |
| 73 | from muse.cli.config import ( |
| 74 | get_remote, |
| 75 | get_remote_head, |
| 76 | get_upstream, |
| 77 | list_remotes, |
| 78 | remove_remote, |
| 79 | rename_remote, |
| 80 | set_remote, |
| 81 | ) |
| 82 | from muse.core.paths import remote_tracking_dir as _remote_tracking_dir |
| 83 | from muse.core.errors import ExitCode |
| 84 | from muse.core.repo import require_repo |
| 85 | from muse.core.validation import sanitize_display |
| 86 | from muse.core.timing import start_timer |
| 87 | |
| 88 | |
| 89 | type _RefMap = dict[str, str] |
| 90 | |
| 91 | logger = logging.getLogger(__name__) |
| 92 | |
| 93 | # Remote name: alphanumeric, dash, underscore, dot. No slashes, no spaces. |
| 94 | _VALID_REMOTE_NAME_CHARS = frozenset( |
| 95 | "abcdefghijklmnopqrstuvwxyz" |
| 96 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" |
| 97 | "0123456789-_." |
| 98 | ) |
| 99 | # Only allow http and https — no file://, ftp://, data://, etc. |
| 100 | _ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) |
| 101 | # Prevent unbounded writes to config.toml. |
| 102 | _MAX_REMOTE_NAME_LEN = 100 |
| 103 | _MAX_URL_LEN = 2048 |
| 104 | |
| 105 | # ── TypedDicts ──────────────────────────────────────────────────────────────── |
| 106 | |
| 107 | class _RemoteEntryJson(TypedDict): |
| 108 | """Single remote entry in ``muse remote --json`` list output.""" |
| 109 | |
| 110 | name: str |
| 111 | url: str |
| 112 | tracking: str | None # "<name>/<upstream_branch>" or null |
| 113 | head: str | None # last-known HEAD sha8 or null |
| 114 | |
| 115 | class _RemoteListJson(EnvelopeJson): |
| 116 | """JSON schema for ``muse remote [--json]``.""" |
| 117 | |
| 118 | remotes: list[_RemoteEntryJson] |
| 119 | |
| 120 | class _RemoteMutationJson(EnvelopeJson, total=False): |
| 121 | """JSON schema for add / remove / rename / set-url subcommands.""" |
| 122 | |
| 123 | status: str # "ok" | "error" |
| 124 | name: str # primary remote name (new name for rename) |
| 125 | url: str | None # applicable URL, null for remove/rename |
| 126 | old_url: str | None # set-url only: the URL before the update |
| 127 | old_name: str | None # rename only |
| 128 | new_name: str | None # rename only |
| 129 | |
| 130 | class _RemoteGetUrlJson(EnvelopeJson): |
| 131 | """JSON schema for ``muse remote get-url``.""" |
| 132 | |
| 133 | name: str |
| 134 | url: str |
| 135 | |
| 136 | class _RemoteStatusJson(EnvelopeJson): |
| 137 | """JSON schema for ``muse remote status``.""" |
| 138 | |
| 139 | remote: str |
| 140 | url: str |
| 141 | server_root: str |
| 142 | reachable: bool |
| 143 | http_status: int | None |
| 144 | message: str |
| 145 | tracked_refs: _RefMap |
| 146 | |
| 147 | # ── Validation helpers ──────────────────────────────────────────────────────── |
| 148 | |
| 149 | def _validate_remote_name(name: str) -> str | None: |
| 150 | """Return an error message if *name* is not a valid remote name, else None.""" |
| 151 | if not name: |
| 152 | return "Remote name must not be empty." |
| 153 | if len(name) > _MAX_REMOTE_NAME_LEN: |
| 154 | return f"Remote name is too long ({len(name)} chars); maximum is {_MAX_REMOTE_NAME_LEN}." |
| 155 | invalid = {c for c in name if c not in _VALID_REMOTE_NAME_CHARS} |
| 156 | if invalid: |
| 157 | shown = ", ".join(repr(c) for c in sorted(invalid)) |
| 158 | return f"Remote name contains invalid characters: {shown}" |
| 159 | return None |
| 160 | |
| 161 | def _validate_url_scheme(url: str) -> str | None: |
| 162 | """Return an error message if *url* does not use an allowed scheme, else None.""" |
| 163 | scheme = urlparse(url).scheme.lower() |
| 164 | if scheme not in _ALLOWED_URL_SCHEMES: |
| 165 | allowed = ", ".join(sorted(_ALLOWED_URL_SCHEMES)) |
| 166 | return f"URL scheme '{sanitize_display(scheme)}' is not allowed. Use one of: {allowed}" |
| 167 | return None |
| 168 | |
| 169 | def _collect_tracked_refs(remotes_dir: pathlib.Path) -> _RefMap: |
| 170 | """Walk *remotes_dir* recursively and return branch → sha8 mapping. |
| 171 | |
| 172 | Handles nested branch names (e.g. ``feat/ui`` stored as |
| 173 | ``remotes_dir/feat/ui``). Symlinks are skipped to prevent path-traversal |
| 174 | attacks — the same guard applied in ``muse fetch``. |
| 175 | """ |
| 176 | refs: _RefMap = {} |
| 177 | if not remotes_dir.exists(): |
| 178 | return refs |
| 179 | _walk_refs(remotes_dir, remotes_dir, refs) |
| 180 | return refs |
| 181 | |
| 182 | def _walk_refs(base: pathlib.Path, current: pathlib.Path, acc: _RefMap) -> None: |
| 183 | """Recursively populate *acc* with branch_name → sha8 from *current*.""" |
| 184 | for entry in sorted(current.iterdir()): |
| 185 | if entry.is_symlink(): |
| 186 | logger.debug("⚠️ Skipping symlink in remotes dir: %s", entry) |
| 187 | continue |
| 188 | if entry.is_dir(): |
| 189 | _walk_refs(base, entry, acc) |
| 190 | elif entry.is_file(): |
| 191 | branch = str(entry.relative_to(base)) |
| 192 | sha = read_ref(entry) |
| 193 | acc[branch] = sha if sha else "(empty)" |
| 194 | |
| 195 | # ── register ────────────────────────────────────────────────────────────────── |
| 196 | |
| 197 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 198 | """Register the ``muse remote`` subcommand tree and all its flags. |
| 199 | |
| 200 | Every subcommand accepts ``--json`` for machine-readable output. All |
| 201 | diagnostic messages (errors, hints) go to stderr in text mode; in JSON |
| 202 | mode all output — including errors — goes to stdout as structured JSON. |
| 203 | """ |
| 204 | parser = subparsers.add_parser( |
| 205 | "remote", |
| 206 | help="Manage remote repository connections.", |
| 207 | description=__doc__, |
| 208 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 209 | ) |
| 210 | parser.add_argument( |
| 211 | "-v", "--verbose", |
| 212 | action="store_true", |
| 213 | help="Show URLs and last-known HEAD with each remote (like git remote -v).", |
| 214 | ) |
| 215 | parser.add_argument( |
| 216 | "--json", "-j", |
| 217 | action="store_true", |
| 218 | dest="json_out", |
| 219 | default=False, |
| 220 | help="Emit JSON to stdout instead of human-readable text.", |
| 221 | ) |
| 222 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 223 | |
| 224 | # ── add ────────────────────────────────────────────────────────────────── |
| 225 | add_p = subs.add_parser( |
| 226 | "add", |
| 227 | help="Register a new remote repository connection.", |
| 228 | description=( |
| 229 | "Register a new named remote in .muse/config.toml.\n\n" |
| 230 | "Remote name rules:\n" |
| 231 | " - Alphanumeric characters, dash (-), underscore (_), and dot (.) only\n" |
| 232 | " - No slashes, spaces, or control characters\n" |
| 233 | f" - Maximum {_MAX_REMOTE_NAME_LEN} characters\n\n" |
| 234 | "URL rules:\n" |
| 235 | " - http:// or https:// only — file://, ftp://, data://, etc. are blocked\n" |
| 236 | f" - Maximum {_MAX_URL_LEN} characters\n" |
| 237 | " - Leading/trailing whitespace is stripped automatically\n\n" |
| 238 | "Agent quickstart:\n" |
| 239 | " muse remote add origin https://musehub.ai/gabriel/my-repo\n" |
| 240 | " muse remote add origin https://musehub.ai/gabriel/my-repo --json\n" |
| 241 | " muse remote add upstream https://musehub.ai/upstream/my-repo\n\n" |
| 242 | "Exit codes:\n" |
| 243 | " 0 Remote added successfully\n" |
| 244 | " 1 Invalid name, invalid URL scheme, or remote already exists\n" |
| 245 | " 2 Not inside a Muse repository" |
| 246 | ), |
| 247 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 248 | ) |
| 249 | add_p.add_argument("name", help="Name for the new remote (e.g. origin).") |
| 250 | add_p.add_argument("url", help="URL of the remote repository (http/https only).") |
| 251 | add_p.add_argument( |
| 252 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 253 | help="Emit JSON to stdout.", |
| 254 | ) |
| 255 | add_p.set_defaults(func=run_add) |
| 256 | |
| 257 | # ── get-url ─────────────────────────────────────────────────────────────── |
| 258 | get_url_p = subs.add_parser( |
| 259 | "get-url", |
| 260 | help="Print the URL of a remote.", |
| 261 | description=( |
| 262 | "Print the URL of a named remote.\n\n" |
| 263 | "In text mode the bare URL is written to stdout — designed for shell\n" |
| 264 | "composition without extra quoting or parsing:\n" |
| 265 | " URL=$(muse remote get-url origin)\n" |
| 266 | " muse push $URL\n\n" |
| 267 | "In JSON mode a structured object is emitted to stdout:\n" |
| 268 | " {\"name\": \"origin\", \"url\": \"https://...\", \"duration_ms\": 1, \"exit_code\": 0}\n\n" |
| 269 | "Agent quickstart:\n" |
| 270 | " muse remote get-url origin\n" |
| 271 | " muse remote get-url origin --json\n" |
| 272 | " muse remote get-url origin -j # same, short flag\n" |
| 273 | " muse remote get-url origin --json | jq -r '.url'\n\n" |
| 274 | "Exit codes:\n" |
| 275 | " 0 URL printed to stdout\n" |
| 276 | " 1 Invalid remote name, or remote does not exist\n" |
| 277 | " 2 Not inside a Muse repository" |
| 278 | ), |
| 279 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 280 | ) |
| 281 | get_url_p.add_argument("name", help="Remote name.") |
| 282 | get_url_p.add_argument( |
| 283 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 284 | help="Emit JSON to stdout.", |
| 285 | ) |
| 286 | get_url_p.set_defaults(func=run_get_url) |
| 287 | |
| 288 | # ── remove ─────────────────────────────────────────────────────────────── |
| 289 | remove_p = subs.add_parser( |
| 290 | "remove", |
| 291 | help="Remove a remote and all its tracking refs.", |
| 292 | description=( |
| 293 | "Remove a named remote from .muse/config.toml and delete its\n" |
| 294 | "tracking refs directory (.muse/remotes/<name>/).\n\n" |
| 295 | "Both the config entry and the tracking refs are deleted atomically\n" |
| 296 | "— if the tracking refs directory does not exist, the command still\n" |
| 297 | "succeeds as long as the config entry is present.\n\n" |
| 298 | "Agent quickstart:\n" |
| 299 | " muse remote remove origin\n" |
| 300 | " muse remote remove origin --json\n" |
| 301 | " muse remote remove origin -j # same, short flag\n\n" |
| 302 | "The --json response includes the removed URL so agents can confirm\n" |
| 303 | "or undo the operation:\n" |
| 304 | " {\"status\": \"ok\", \"name\": \"origin\", \"url\": \"https://...\", ...}\n\n" |
| 305 | "Exit codes:\n" |
| 306 | " 0 Remote removed successfully\n" |
| 307 | " 1 Remote does not exist, or name is invalid\n" |
| 308 | " 2 Not inside a Muse repository" |
| 309 | ), |
| 310 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 311 | ) |
| 312 | remove_p.add_argument("name", help="Name of the remote to remove.") |
| 313 | remove_p.add_argument( |
| 314 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 315 | help="Emit JSON to stdout.", |
| 316 | ) |
| 317 | remove_p.set_defaults(func=run_remove) |
| 318 | |
| 319 | # ── rename ─────────────────────────────────────────────────────────────── |
| 320 | rename_p = subs.add_parser( |
| 321 | "rename", |
| 322 | help="Rename a remote and move its tracking refs.", |
| 323 | description=( |
| 324 | "Rename a remote in .muse/config.toml and move its tracking refs\n" |
| 325 | "directory from .muse/remotes/<old_name>/ to .muse/remotes/<new_name>/.\n\n" |
| 326 | "Both <old_name> and <new_name> are validated against remote name rules\n" |
| 327 | "(alphanumeric + dash, underscore, dot; max 100 chars) before any write.\n\n" |
| 328 | "Agent quickstart:\n" |
| 329 | " muse remote rename origin upstream\n" |
| 330 | " muse remote rename origin upstream --json\n" |
| 331 | " muse remote rename origin upstream -j # same, short flag\n\n" |
| 332 | "The --json response includes the URL so agents can verify the rename:\n" |
| 333 | " {\"status\": \"ok\", \"name\": \"upstream\",\n" |
| 334 | " \"url\": \"https://...\", \"old_name\": \"origin\", \"new_name\": \"upstream\"}\n\n" |
| 335 | "Exit codes:\n" |
| 336 | " 0 Remote renamed successfully\n" |
| 337 | " 1 Invalid name, old name does not exist, or new name already taken\n" |
| 338 | " 2 Not inside a Muse repository" |
| 339 | ), |
| 340 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 341 | ) |
| 342 | rename_p.add_argument("old_name", help="Current remote name.") |
| 343 | rename_p.add_argument("new_name", help="New remote name.") |
| 344 | rename_p.add_argument( |
| 345 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 346 | help="Emit JSON to stdout.", |
| 347 | ) |
| 348 | rename_p.set_defaults(func=run_rename) |
| 349 | |
| 350 | # ── set-url ─────────────────────────────────────────────────────────────── |
| 351 | set_url_p = subs.add_parser( |
| 352 | "set-url", |
| 353 | help="Update the URL of an existing remote.", |
| 354 | description=( |
| 355 | "Update the URL of an existing named remote in .muse/config.toml.\n\n" |
| 356 | "Remote name rules:\n" |
| 357 | " - Alphanumeric characters, dash (-), underscore (_), and dot (.) only\n" |
| 358 | " - No slashes, spaces, or control characters\n" |
| 359 | f" - Maximum {_MAX_REMOTE_NAME_LEN} characters\n\n" |
| 360 | "URL rules:\n" |
| 361 | " - http:// or https:// only — file://, ftp://, data://, etc. are blocked\n" |
| 362 | f" - Maximum {_MAX_URL_LEN} characters\n" |
| 363 | " - Leading/trailing whitespace is stripped automatically\n\n" |
| 364 | "Agent quickstart:\n" |
| 365 | " muse remote set-url origin https://musehub.ai/gabriel/new-repo\n" |
| 366 | " muse remote set-url origin https://musehub.ai/gabriel/new-repo --json\n" |
| 367 | " muse remote set-url origin https://musehub.ai/gabriel/new-repo -j\n\n" |
| 368 | "The --json response includes old_url so agents can confirm or undo:\n" |
| 369 | " {\"status\": \"ok\", \"name\": \"origin\",\n" |
| 370 | " \"url\": \"https://...(new)\", \"old_url\": \"https://...(old)\", ...}\n\n" |
| 371 | "Exit codes:\n" |
| 372 | " 0 URL updated successfully\n" |
| 373 | " 1 Invalid name, invalid URL scheme, oversized URL, or remote does not exist\n" |
| 374 | " 2 Not inside a Muse repository" |
| 375 | ), |
| 376 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 377 | ) |
| 378 | set_url_p.add_argument("name", help="Remote name.") |
| 379 | set_url_p.add_argument("url", help="New URL for the remote (http/https only).") |
| 380 | set_url_p.add_argument( |
| 381 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 382 | help="Emit JSON to stdout.", |
| 383 | ) |
| 384 | set_url_p.set_defaults(func=run_set_url) |
| 385 | |
| 386 | # ── status ──────────────────────────────────────────────────────────────── |
| 387 | status_p = subs.add_parser( |
| 388 | "status", |
| 389 | help="Check reachability and last-known refs for a remote (read-only, no fetch).", |
| 390 | description=( |
| 391 | "Ping a remote's /health endpoint and show locally cached tracking refs.\n\n" |
| 392 | "This command is READ-ONLY — it never fetches, writes, or modifies local state.\n" |
| 393 | "Use it to verify a hub is reachable before running 'muse push' or 'muse fetch'.\n\n" |
| 394 | "The tracking refs shown are cached from previous fetch/push operations and are\n" |
| 395 | "only as current as the last 'muse fetch'. An empty refs list does not mean the\n" |
| 396 | "remote is empty — it means no fetch has been run yet.\n\n" |
| 397 | "Agent quickstart:\n" |
| 398 | " muse remote status origin\n" |
| 399 | " muse remote status origin --json\n" |
| 400 | " muse remote status origin -j # same, short flag\n" |
| 401 | " muse remote status origin --json --timeout 10\n" |
| 402 | " muse remote status origin --json | jq '.reachable'\n\n" |
| 403 | "JSON schema:\n" |
| 404 | " {\"remote\": \"origin\", \"url\": \"https://...\", \"server_root\": \"https://...\",\n" |
| 405 | " \"reachable\": true|false, \"http_status\": <N>|null, \"message\": \"...\",\n" |
| 406 | " \"tracked_refs\": {\"main\": \"<sha8>\", \"feat/ui\": \"<sha8>\"},\n" |
| 407 | " \"duration_ms\": 42, \"exit_code\": 0}\n\n" |
| 408 | "Exit codes:\n" |
| 409 | " 0 Remote is reachable\n" |
| 410 | " 1 Invalid remote name, or remote does not exist\n" |
| 411 | " 2 Not inside a Muse repository\n" |
| 412 | " 5 Remote is unreachable (network error, timeout, or non-2xx response)" |
| 413 | ), |
| 414 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 415 | ) |
| 416 | status_p.add_argument("name", help="Remote name.") |
| 417 | status_p.add_argument( |
| 418 | "--json", "-j", action="store_true", dest="json_out", default=False, |
| 419 | help="Emit JSON to stdout instead of human-readable output.", |
| 420 | ) |
| 421 | status_p.add_argument( |
| 422 | "--timeout", dest="timeout", type=float, default=6.0, |
| 423 | help="HTTP connect timeout in seconds (default: 6).", |
| 424 | ) |
| 425 | status_p.set_defaults(func=run_status) |
| 426 | |
| 427 | parser.set_defaults(func=run) |
| 428 | |
| 429 | # ── list (no subcommand) ───────────────────────────────────────────────────── |
| 430 | |
| 431 | def run(args: argparse.Namespace) -> None: |
| 432 | """List configured remotes. |
| 433 | |
| 434 | With no flags prints bare names (one per line). With ``-v``/``--verbose`` |
| 435 | prints fetch and push lines with URL and last-known HEAD (mirroring |
| 436 | ``git remote -v``). With ``--json`` emits a :class:`_RemoteListJson` |
| 437 | object on stdout; all other output goes to stderr. |
| 438 | |
| 439 | JSON envelope fields present on every response: |
| 440 | |
| 441 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 442 | - ``exit_code`` — 0 on success; matches the process exit code. |
| 443 | """ |
| 444 | elapsed = start_timer() |
| 445 | verbose: bool = args.verbose |
| 446 | json_out: bool = args.json_out |
| 447 | |
| 448 | root = require_repo() |
| 449 | remotes = list_remotes(root) |
| 450 | |
| 451 | if json_out: |
| 452 | entries: list[_RemoteEntryJson] = [] |
| 453 | for r in remotes: |
| 454 | upstream = get_upstream(r["name"], root) |
| 455 | head = get_remote_head(r["name"], upstream or "main", root) if upstream else None |
| 456 | entries.append({ |
| 457 | "name": r["name"], |
| 458 | "url": r["url"], |
| 459 | "tracking": f"{r['name']}/{upstream}" if upstream else None, |
| 460 | "head": head if head else None, |
| 461 | }) |
| 462 | out = _RemoteListJson(**make_envelope(elapsed), remotes=entries) |
| 463 | print(json.dumps(out)) |
| 464 | return |
| 465 | |
| 466 | if not remotes: |
| 467 | print("No remotes configured. Use 'muse remote add <name> <url>'.", file=sys.stderr) |
| 468 | return |
| 469 | |
| 470 | name_width = max(len(r["name"]) for r in remotes) |
| 471 | for r in remotes: |
| 472 | if verbose: |
| 473 | upstream = get_upstream(r["name"], root) |
| 474 | head = get_remote_head(r["name"], upstream or "main", root) |
| 475 | head_str = f" @ {head}" if head else "" |
| 476 | tracking = f" -> {r['name']}/{upstream}" if upstream else "" |
| 477 | label = f"{r['name']:<{name_width}}" |
| 478 | print(f"{sanitize_display(label)}\t{sanitize_display(r['url'])}{tracking}{head_str} (fetch)") |
| 479 | print(f"{sanitize_display(label)}\t{sanitize_display(r['url'])}{tracking}{head_str} (push)") |
| 480 | else: |
| 481 | print(r["name"]) |
| 482 | |
| 483 | # ── add ─────────────────────────────────────────────────────────────────────── |
| 484 | |
| 485 | def run_add(args: argparse.Namespace) -> None: |
| 486 | """Register a new remote repository connection. |
| 487 | |
| 488 | Validates all inputs before any write: |
| 489 | |
| 490 | - Remote name: alphanumeric + ``-_.``, no slashes/spaces/control chars, |
| 491 | max :data:`_MAX_REMOTE_NAME_LEN` characters. |
| 492 | - URL: ``http`` or ``https`` scheme only; leading/trailing whitespace is |
| 493 | stripped before validation so pasted URLs with trailing newlines work |
| 494 | correctly. Max :data:`_MAX_URL_LEN` characters. |
| 495 | - Duplicate check: exits with a hint to use ``muse remote set-url`` when |
| 496 | the remote already exists. |
| 497 | |
| 498 | In JSON mode (``--json``), errors are returned as structured JSON on stdout |
| 499 | only — no additional text is written to stderr. Diagnostics go to stderr |
| 500 | only in text mode. |
| 501 | |
| 502 | JSON envelope fields present on every response (success and error): |
| 503 | |
| 504 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 505 | - ``exit_code`` — 0 on success; 1 on user error; matches the process exit code. |
| 506 | |
| 507 | Exit codes: |
| 508 | 0 Remote written to ``.muse/config.toml``. |
| 509 | 1 Invalid name, invalid/oversized URL, or remote already exists. |
| 510 | 2 Not inside a Muse repository. |
| 511 | """ |
| 512 | elapsed = start_timer() |
| 513 | name: str = args.name |
| 514 | url: str = args.url.strip() # strip whitespace — pasted URLs often have trailing newlines |
| 515 | json_out: bool = args.json_out |
| 516 | |
| 517 | if err := _validate_remote_name(name): |
| 518 | if json_out: |
| 519 | print(json.dumps({ |
| 520 | "status": "error", "error": "invalid_name", "name": name, "message": err, |
| 521 | "duration_ms": int(elapsed()), |
| 522 | "exit_code": int(ExitCode.USER_ERROR), |
| 523 | })) |
| 524 | else: |
| 525 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 526 | raise SystemExit(ExitCode.USER_ERROR) |
| 527 | |
| 528 | if len(url) > _MAX_URL_LEN: |
| 529 | if json_out: |
| 530 | print(json.dumps({ |
| 531 | "status": "error", "error": "url_too_long", "name": name, |
| 532 | "message": f"URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}", |
| 533 | "duration_ms": int(elapsed()), |
| 534 | "exit_code": int(ExitCode.USER_ERROR), |
| 535 | })) |
| 536 | else: |
| 537 | print( |
| 538 | f"❌ URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}.", |
| 539 | file=sys.stderr, |
| 540 | ) |
| 541 | raise SystemExit(ExitCode.USER_ERROR) |
| 542 | |
| 543 | if err := _validate_url_scheme(url): |
| 544 | if json_out: |
| 545 | print(json.dumps({ |
| 546 | "status": "error", "error": "invalid_url", "name": name, "message": err, |
| 547 | "duration_ms": int(elapsed()), |
| 548 | "exit_code": int(ExitCode.USER_ERROR), |
| 549 | })) |
| 550 | else: |
| 551 | print(f"❌ {err}", file=sys.stderr) |
| 552 | raise SystemExit(ExitCode.USER_ERROR) |
| 553 | |
| 554 | root = require_repo() |
| 555 | existing = get_remote(name, root) |
| 556 | if existing is not None: |
| 557 | if json_out: |
| 558 | print(json.dumps({ |
| 559 | "status": "error", "error": "already_exists", "name": name, "url": existing, |
| 560 | "message": f"remote '{name}' already exists", |
| 561 | "hint": f"muse remote set-url {name} <url>", |
| 562 | "duration_ms": int(elapsed()), |
| 563 | "exit_code": int(ExitCode.USER_ERROR), |
| 564 | })) |
| 565 | else: |
| 566 | print( |
| 567 | f"❌ Remote '{sanitize_display(name)}' already exists: {sanitize_display(existing)}", |
| 568 | file=sys.stderr, |
| 569 | ) |
| 570 | print( |
| 571 | f" Use 'muse remote set-url {sanitize_display(name)} <url>' to update it.", |
| 572 | file=sys.stderr, |
| 573 | ) |
| 574 | raise SystemExit(ExitCode.USER_ERROR) |
| 575 | |
| 576 | set_remote(name, url, root) |
| 577 | |
| 578 | if json_out: |
| 579 | result = _RemoteMutationJson( |
| 580 | **make_envelope(elapsed), |
| 581 | status="ok", |
| 582 | name=name, |
| 583 | url=url, |
| 584 | old_url=None, |
| 585 | old_name=None, |
| 586 | new_name=None, |
| 587 | ) |
| 588 | print(json.dumps(result)) |
| 589 | else: |
| 590 | print(f"✅ Remote '{sanitize_display(name)}' added: {sanitize_display(url)}", file=sys.stderr) |
| 591 | |
| 592 | # ── remove ──────────────────────────────────────────────────────────────────── |
| 593 | |
| 594 | def run_remove(args: argparse.Namespace) -> None: |
| 595 | """Remove a remote and all its tracking refs. |
| 596 | |
| 597 | Validates the remote name format first (same rules as ``muse remote add``) |
| 598 | so invalid-looking names produce a clear format error rather than a |
| 599 | misleading "does not exist" message. |
| 600 | |
| 601 | The removed URL is captured before deletion and included in the |
| 602 | ``--json`` response, giving agents enough information to confirm the |
| 603 | correct remote was removed or to undo the operation with |
| 604 | ``muse remote add``. |
| 605 | |
| 606 | The tracking refs directory (``.muse/remotes/<name>/``) is removed with |
| 607 | ``shutil.rmtree`` if present. If that path is a symlink the deletion is |
| 608 | skipped and a warning is logged — following a symlink could delete files |
| 609 | outside the repository tree. |
| 610 | |
| 611 | In JSON mode (``--json``), errors are returned as structured JSON on stdout |
| 612 | only — no additional text is written to stderr. |
| 613 | |
| 614 | JSON envelope fields present on every response (success and error): |
| 615 | |
| 616 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 617 | - ``exit_code`` — 0 on success; 1 on user error; matches the process exit code. |
| 618 | |
| 619 | Exit codes: |
| 620 | 0 Remote removed from config and tracking refs cleaned up. |
| 621 | 1 Invalid name, or remote does not exist. |
| 622 | 2 Not inside a Muse repository. |
| 623 | """ |
| 624 | elapsed = start_timer() |
| 625 | name: str = args.name |
| 626 | json_out: bool = args.json_out |
| 627 | |
| 628 | if err := _validate_remote_name(name): |
| 629 | if json_out: |
| 630 | print(json.dumps({ |
| 631 | "status": "error", "error": "invalid_name", "name": name, "message": err, |
| 632 | "duration_ms": int(elapsed()), |
| 633 | "exit_code": int(ExitCode.USER_ERROR), |
| 634 | })) |
| 635 | else: |
| 636 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 637 | raise SystemExit(ExitCode.USER_ERROR) |
| 638 | |
| 639 | root = require_repo() |
| 640 | |
| 641 | # Capture the URL before removal so it can be returned in JSON output. |
| 642 | removed_url: str | None = get_remote(name, root) |
| 643 | if removed_url is None: |
| 644 | if json_out: |
| 645 | print(json.dumps({ |
| 646 | "status": "error", "error": "not_found", "name": name, |
| 647 | "message": f"remote '{name}' does not exist", |
| 648 | "duration_ms": int(elapsed()), |
| 649 | "exit_code": int(ExitCode.USER_ERROR), |
| 650 | })) |
| 651 | else: |
| 652 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 653 | raise SystemExit(ExitCode.USER_ERROR) |
| 654 | |
| 655 | try: |
| 656 | remove_remote(name, root) |
| 657 | except KeyError: |
| 658 | if json_out: |
| 659 | print(json.dumps({ |
| 660 | "status": "error", "error": "not_found", "name": name, |
| 661 | "message": f"remote '{name}' does not exist", |
| 662 | "duration_ms": int(elapsed()), |
| 663 | "exit_code": int(ExitCode.USER_ERROR), |
| 664 | })) |
| 665 | else: |
| 666 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 667 | raise SystemExit(ExitCode.USER_ERROR) |
| 668 | |
| 669 | if json_out: |
| 670 | result = _RemoteMutationJson( |
| 671 | **make_envelope(elapsed), |
| 672 | status="ok", |
| 673 | name=name, |
| 674 | url=removed_url, |
| 675 | old_url=None, |
| 676 | old_name=None, |
| 677 | new_name=None, |
| 678 | ) |
| 679 | print(json.dumps(result)) |
| 680 | else: |
| 681 | print(f"✅ Remote '{sanitize_display(name)}' removed.", file=sys.stderr) |
| 682 | |
| 683 | # ── rename ──────────────────────────────────────────────────────────────────── |
| 684 | |
| 685 | def run_rename(args: argparse.Namespace) -> None: |
| 686 | """Rename a remote and move its tracking refs. |
| 687 | |
| 688 | Both *old_name* and *new_name* are validated against remote name rules |
| 689 | before any repo or filesystem access, so invalid-looking names produce a |
| 690 | clear format error rather than a confusing "does not exist" message. |
| 691 | |
| 692 | The URL is looked up before the rename and included in the ``--json`` |
| 693 | response so agents can verify which remote was renamed. |
| 694 | |
| 695 | The tracking refs directory (``.muse/remotes/<old_name>/``) is moved to |
| 696 | ``.muse/remotes/<new_name>/`` via an atomic ``os.rename`` if it exists. |
| 697 | |
| 698 | In JSON mode (``--json``), errors are returned as structured JSON on stdout |
| 699 | only — no additional text is written to stderr. |
| 700 | |
| 701 | JSON envelope fields present on every response (success and error): |
| 702 | |
| 703 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 704 | - ``exit_code`` — 0 on success; 1 on user error; matches the process exit code. |
| 705 | |
| 706 | Exit codes: |
| 707 | 0 Remote renamed in config and tracking refs moved. |
| 708 | 1 Invalid name, old remote does not exist, or new name already taken. |
| 709 | 2 Not inside a Muse repository. |
| 710 | """ |
| 711 | elapsed = start_timer() |
| 712 | old_name: str = args.old_name |
| 713 | new_name: str = args.new_name |
| 714 | json_out: bool = args.json_out |
| 715 | |
| 716 | if err := _validate_remote_name(old_name): |
| 717 | if json_out: |
| 718 | print(json.dumps({ |
| 719 | "status": "error", "error": "invalid_name", "name": old_name, "message": err, |
| 720 | "duration_ms": int(elapsed()), |
| 721 | "exit_code": int(ExitCode.USER_ERROR), |
| 722 | })) |
| 723 | else: |
| 724 | print(f"❌ Invalid remote name '{sanitize_display(old_name)}': {err}", file=sys.stderr) |
| 725 | raise SystemExit(ExitCode.USER_ERROR) |
| 726 | |
| 727 | if err := _validate_remote_name(new_name): |
| 728 | if json_out: |
| 729 | print(json.dumps({ |
| 730 | "status": "error", "error": "invalid_name", "name": new_name, "message": err, |
| 731 | "duration_ms": int(elapsed()), |
| 732 | "exit_code": int(ExitCode.USER_ERROR), |
| 733 | })) |
| 734 | else: |
| 735 | print(f"❌ Invalid remote name '{sanitize_display(new_name)}': {err}", file=sys.stderr) |
| 736 | raise SystemExit(ExitCode.USER_ERROR) |
| 737 | |
| 738 | root = require_repo() |
| 739 | |
| 740 | # Capture the URL before renaming so it can be returned in JSON output. |
| 741 | renamed_url: str | None = get_remote(old_name, root) |
| 742 | |
| 743 | try: |
| 744 | rename_remote(old_name, new_name, root) |
| 745 | except KeyError: |
| 746 | if json_out: |
| 747 | print(json.dumps({ |
| 748 | "status": "error", "error": "not_found", "name": old_name, |
| 749 | "message": f"remote '{old_name}' does not exist", |
| 750 | "duration_ms": int(elapsed()), |
| 751 | "exit_code": int(ExitCode.USER_ERROR), |
| 752 | })) |
| 753 | else: |
| 754 | print(f"❌ Remote '{sanitize_display(old_name)}' does not exist.", file=sys.stderr) |
| 755 | raise SystemExit(ExitCode.USER_ERROR) |
| 756 | except ValueError: |
| 757 | if json_out: |
| 758 | print(json.dumps({ |
| 759 | "status": "error", "error": "already_exists", "name": new_name, |
| 760 | "message": f"remote '{new_name}' already exists", |
| 761 | "duration_ms": int(elapsed()), |
| 762 | "exit_code": int(ExitCode.USER_ERROR), |
| 763 | })) |
| 764 | else: |
| 765 | print(f"❌ Remote '{sanitize_display(new_name)}' already exists.", file=sys.stderr) |
| 766 | raise SystemExit(ExitCode.USER_ERROR) |
| 767 | |
| 768 | if json_out: |
| 769 | result = _RemoteMutationJson( |
| 770 | **make_envelope(elapsed), |
| 771 | status="ok", |
| 772 | name=new_name, |
| 773 | url=renamed_url, |
| 774 | old_url=None, |
| 775 | old_name=old_name, |
| 776 | new_name=new_name, |
| 777 | ) |
| 778 | print(json.dumps(result)) |
| 779 | else: |
| 780 | print( |
| 781 | f"✅ Remote '{sanitize_display(old_name)}' renamed to '{sanitize_display(new_name)}'.", |
| 782 | file=sys.stderr, |
| 783 | ) |
| 784 | |
| 785 | # ── get-url ─────────────────────────────────────────────────────────────────── |
| 786 | |
| 787 | def run_get_url(args: argparse.Namespace) -> None: |
| 788 | """Print the URL of a remote. |
| 789 | |
| 790 | Validates the remote name format before any repo or filesystem access so |
| 791 | an invalid-looking name produces a clear format error rather than a |
| 792 | misleading "does not exist" message. |
| 793 | |
| 794 | In text mode the bare URL is printed to stdout via |
| 795 | :func:`~muse.core.validation.sanitize_display` so ANSI escape codes that |
| 796 | might have been placed in ``config.toml`` by direct editing cannot inject |
| 797 | terminal control sequences. For shell composition the sanitized URL is |
| 798 | virtually always identical to the stored one — valid URLs contain no ANSI. |
| 799 | |
| 800 | In JSON mode a :class:`_RemoteGetUrlJson` object is emitted to stdout; |
| 801 | JSON string encoding neutralises any control characters in the value. |
| 802 | Errors in JSON mode are returned as structured JSON on stdout only — no |
| 803 | additional text is written to stderr. |
| 804 | |
| 805 | JSON envelope fields present on every response (success and error): |
| 806 | |
| 807 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 808 | - ``exit_code`` — 0 on success; 1 on user error; matches the process exit code. |
| 809 | |
| 810 | Exit codes: |
| 811 | 0 URL printed to stdout. |
| 812 | 1 Invalid remote name, or remote does not exist. |
| 813 | 2 Not inside a Muse repository. |
| 814 | """ |
| 815 | elapsed = start_timer() |
| 816 | name: str = args.name |
| 817 | json_out: bool = args.json_out |
| 818 | |
| 819 | if err := _validate_remote_name(name): |
| 820 | if json_out: |
| 821 | print(json.dumps({ |
| 822 | "error": "invalid_name", "name": name, "message": err, |
| 823 | "duration_ms": int(elapsed()), |
| 824 | "exit_code": int(ExitCode.USER_ERROR), |
| 825 | })) |
| 826 | else: |
| 827 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 828 | raise SystemExit(ExitCode.USER_ERROR) |
| 829 | |
| 830 | root = require_repo() |
| 831 | url = get_remote(name, root) |
| 832 | if url is None: |
| 833 | if json_out: |
| 834 | print(json.dumps({ |
| 835 | "error": "not_found", "name": name, |
| 836 | "message": f"remote '{name}' does not exist", |
| 837 | "duration_ms": int(elapsed()), |
| 838 | "exit_code": int(ExitCode.USER_ERROR), |
| 839 | })) |
| 840 | else: |
| 841 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 842 | raise SystemExit(ExitCode.USER_ERROR) |
| 843 | |
| 844 | if json_out: |
| 845 | out = _RemoteGetUrlJson(**make_envelope(elapsed), name=name, url=url) |
| 846 | print(json.dumps(out)) |
| 847 | else: |
| 848 | # Bare URL on stdout — intended for shell composition: $(muse remote get-url origin) |
| 849 | # sanitize_display strips ANSI/control chars that might appear in a hand-edited config. |
| 850 | print(sanitize_display(url)) |
| 851 | |
| 852 | # ── set-url ─────────────────────────────────────────────────────────────────── |
| 853 | |
| 854 | def run_set_url(args: argparse.Namespace) -> None: |
| 855 | """Update the URL of an existing remote. |
| 856 | |
| 857 | Validates all inputs before any write: |
| 858 | |
| 859 | - Remote name: alphanumeric + ``-_.``, no slashes/spaces/control chars, |
| 860 | max :data:`_MAX_REMOTE_NAME_LEN` characters. Validated before |
| 861 | :func:`~muse.core.repo.require_repo` so invalid-looking names produce a |
| 862 | clear format error rather than a "does not exist" message. |
| 863 | - URL: ``http`` or ``https`` scheme only; leading/trailing whitespace is |
| 864 | stripped before validation so pasted URLs with trailing newlines work |
| 865 | correctly. Max :data:`_MAX_URL_LEN` characters. |
| 866 | |
| 867 | The previous URL is captured before the write and included in the |
| 868 | ``--json`` response as ``old_url``, giving agents enough information to |
| 869 | confirm the correct remote was updated or to undo the operation with |
| 870 | another ``muse remote set-url``. |
| 871 | |
| 872 | In JSON mode (``--json``), errors are returned as structured JSON on stdout |
| 873 | only — no additional text is written to stderr. Diagnostics go to stderr |
| 874 | only in text mode. |
| 875 | |
| 876 | JSON envelope fields present on every response (success and error): |
| 877 | |
| 878 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 879 | - ``exit_code`` — 0 on success; 1 on user error; matches the process exit code. |
| 880 | |
| 881 | Exit codes: |
| 882 | 0 URL updated in ``.muse/config.toml``. |
| 883 | 1 Invalid name, invalid/oversized URL, or remote does not exist. |
| 884 | 2 Not inside a Muse repository. |
| 885 | """ |
| 886 | elapsed = start_timer() |
| 887 | name: str = args.name |
| 888 | url: str = args.url.strip() # strip whitespace — pasted URLs often have trailing newlines |
| 889 | json_out: bool = args.json_out |
| 890 | |
| 891 | if err := _validate_remote_name(name): |
| 892 | if json_out: |
| 893 | print(json.dumps({ |
| 894 | "status": "error", "error": "invalid_name", "name": name, "message": err, |
| 895 | "duration_ms": int(elapsed()), |
| 896 | "exit_code": int(ExitCode.USER_ERROR), |
| 897 | })) |
| 898 | else: |
| 899 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 900 | raise SystemExit(ExitCode.USER_ERROR) |
| 901 | |
| 902 | if len(url) > _MAX_URL_LEN: |
| 903 | if json_out: |
| 904 | print(json.dumps({ |
| 905 | "status": "error", "error": "url_too_long", "name": name, |
| 906 | "message": f"URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}", |
| 907 | "duration_ms": int(elapsed()), |
| 908 | "exit_code": int(ExitCode.USER_ERROR), |
| 909 | })) |
| 910 | else: |
| 911 | print( |
| 912 | f"❌ URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}.", |
| 913 | file=sys.stderr, |
| 914 | ) |
| 915 | raise SystemExit(ExitCode.USER_ERROR) |
| 916 | |
| 917 | if err := _validate_url_scheme(url): |
| 918 | if json_out: |
| 919 | print(json.dumps({ |
| 920 | "status": "error", "error": "invalid_url", "name": name, "message": err, |
| 921 | "duration_ms": int(elapsed()), |
| 922 | "exit_code": int(ExitCode.USER_ERROR), |
| 923 | })) |
| 924 | else: |
| 925 | print(f"❌ {err}", file=sys.stderr) |
| 926 | raise SystemExit(ExitCode.USER_ERROR) |
| 927 | |
| 928 | root = require_repo() |
| 929 | old_url = get_remote(name, root) |
| 930 | if old_url is None: |
| 931 | if json_out: |
| 932 | print(json.dumps({ |
| 933 | "status": "error", "error": "not_found", "name": name, |
| 934 | "message": f"remote '{name}' does not exist", |
| 935 | "hint": f"muse remote add {name} <url>", |
| 936 | "duration_ms": int(elapsed()), |
| 937 | "exit_code": int(ExitCode.USER_ERROR), |
| 938 | })) |
| 939 | else: |
| 940 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 941 | print( |
| 942 | f" Use 'muse remote add {sanitize_display(name)} <url>' to create it.", |
| 943 | file=sys.stderr, |
| 944 | ) |
| 945 | raise SystemExit(ExitCode.USER_ERROR) |
| 946 | |
| 947 | set_remote(name, url, root) |
| 948 | |
| 949 | if json_out: |
| 950 | result = _RemoteMutationJson( |
| 951 | **make_envelope(elapsed), |
| 952 | status="ok", |
| 953 | name=name, |
| 954 | url=url, |
| 955 | old_url=old_url, |
| 956 | old_name=None, |
| 957 | new_name=None, |
| 958 | ) |
| 959 | print(json.dumps(result)) |
| 960 | else: |
| 961 | print( |
| 962 | f"✅ Remote '{sanitize_display(name)}' URL updated: {sanitize_display(url)}", |
| 963 | file=sys.stderr, |
| 964 | ) |
| 965 | |
| 966 | # ── _ping_url ───────────────────────────────────────────────────────────────── |
| 967 | |
| 968 | def _ping_url(base_url: str, timeout: float) -> tuple[bool, int | None, str]: |
| 969 | """Ping ``<base_url>/health``. |
| 970 | |
| 971 | Returns ``(reachable, http_status, message)``. |
| 972 | |
| 973 | Only ``http`` and ``https`` schemes are accepted — any other scheme is |
| 974 | treated as unreachable without making a network request to prevent SSRF |
| 975 | via ``file://`` or ``ftp://`` URLs stored in config. |
| 976 | """ |
| 977 | scheme = urlparse(base_url).scheme.lower() |
| 978 | if scheme not in _ALLOWED_URL_SCHEMES: |
| 979 | return False, None, f"Unsupported URL scheme '{sanitize_display(scheme)}'" |
| 980 | |
| 981 | health_url = f"{base_url.rstrip('/')}/health" |
| 982 | try: |
| 983 | req = urllib.request.Request(health_url, method="GET") |
| 984 | with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 |
| 985 | return True, resp.status, f"HTTP {resp.status} OK" |
| 986 | except urllib.error.HTTPError as exc: |
| 987 | return False, exc.code, f"HTTP {exc.code} {exc.reason}" |
| 988 | except urllib.error.URLError as exc: |
| 989 | return False, None, str(exc.reason) |
| 990 | except TimeoutError: |
| 991 | return False, None, f"timed out after {timeout}s" |
| 992 | except OSError as exc: |
| 993 | return False, None, str(exc) |
| 994 | |
| 995 | # ── status ──────────────────────────────────────────────────────────────────── |
| 996 | |
| 997 | def run_status(args: argparse.Namespace) -> None: |
| 998 | """Check reachability and last-known tracking refs for a remote. |
| 999 | |
| 1000 | This command is **read-only** — it does not fetch, write, or modify any |
| 1001 | local state. Use it to inspect a remote before running ``muse push`` or |
| 1002 | ``muse fetch``:: |
| 1003 | |
| 1004 | muse remote status origin |
| 1005 | muse remote status origin --json |
| 1006 | muse remote status origin --json --timeout 10 |
| 1007 | |
| 1008 | Validates the remote name format before any repo or filesystem access so |
| 1009 | invalid-looking names produce a clear format error rather than a misleading |
| 1010 | "does not exist" message. |
| 1011 | |
| 1012 | Pings ``<server_root>/health`` (derived from the stored URL) and reports |
| 1013 | locally cached tracking data from previous fetch/push operations. The |
| 1014 | tracking data is only as current as the last ``muse fetch``. |
| 1015 | |
| 1016 | Tracking refs are collected recursively so that nested branch names like |
| 1017 | ``feat/ui`` (stored as ``remotes/origin/feat/ui``) are shown correctly. |
| 1018 | Symlinks inside the remotes directory are skipped to prevent path traversal. |
| 1019 | |
| 1020 | In JSON mode (``--json``), all output — including errors — goes to stdout |
| 1021 | as structured JSON. Human-readable text goes to stderr only in text mode. |
| 1022 | |
| 1023 | JSON envelope fields present on every response (success and error): |
| 1024 | |
| 1025 | - ``duration_ms`` — wall-clock milliseconds from function entry to emit. |
| 1026 | - ``exit_code`` — 0 if reachable; 5 if unreachable; 1 on name/config error. |
| 1027 | Always matches the process exit code. |
| 1028 | |
| 1029 | Exit codes: |
| 1030 | 0 Remote reachable and status printed. |
| 1031 | 1 Invalid remote name, or remote does not exist. |
| 1032 | 2 Not inside a Muse repository. |
| 1033 | 5 Remote is unreachable (network error, timeout, or non-2xx response). |
| 1034 | """ |
| 1035 | elapsed = start_timer() |
| 1036 | name: str = args.name |
| 1037 | json_out: bool = args.json_out |
| 1038 | timeout: float = args.timeout |
| 1039 | |
| 1040 | if err := _validate_remote_name(name): |
| 1041 | if json_out: |
| 1042 | print(json.dumps({ |
| 1043 | "error": "invalid_name", "name": name, "message": err, |
| 1044 | "duration_ms": int(elapsed()), |
| 1045 | "exit_code": int(ExitCode.USER_ERROR), |
| 1046 | })) |
| 1047 | else: |
| 1048 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 1049 | raise SystemExit(ExitCode.USER_ERROR) |
| 1050 | |
| 1051 | root = require_repo() |
| 1052 | url = get_remote(name, root) |
| 1053 | if url is None: |
| 1054 | if json_out: |
| 1055 | print(json.dumps({ |
| 1056 | "error": "not_found", "name": name, |
| 1057 | "message": f"remote '{name}' does not exist", |
| 1058 | "duration_ms": int(elapsed()), |
| 1059 | "exit_code": int(ExitCode.USER_ERROR), |
| 1060 | })) |
| 1061 | else: |
| 1062 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 1063 | raise SystemExit(ExitCode.USER_ERROR) |
| 1064 | |
| 1065 | # Extract the server root URL: http://host[:port]/owner/repo → http://host[:port] |
| 1066 | parsed = urlparse(url) |
| 1067 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 1068 | |
| 1069 | reachable, http_status, message = _ping_url(server_root, timeout) |
| 1070 | |
| 1071 | # Recursively collect tracking refs — handles nested branch names like |
| 1072 | # "feat/ui" and skips symlinks (path-traversal guard). |
| 1073 | remotes_dir = _remote_tracking_dir(root, name) |
| 1074 | tracked_refs = _collect_tracked_refs(remotes_dir) |
| 1075 | |
| 1076 | exit_code_val = 0 if reachable else int(ExitCode.REMOTE_ERROR) |
| 1077 | |
| 1078 | if json_out: |
| 1079 | out = _RemoteStatusJson( |
| 1080 | **make_envelope(elapsed, exit_code=exit_code_val), |
| 1081 | remote=name, |
| 1082 | url=url, |
| 1083 | server_root=server_root, |
| 1084 | reachable=reachable, |
| 1085 | http_status=http_status, |
| 1086 | message=message, |
| 1087 | tracked_refs=tracked_refs, |
| 1088 | ) |
| 1089 | print(json.dumps(out)) |
| 1090 | else: |
| 1091 | status_icon = "✅" if reachable else "❌" |
| 1092 | print(f"\n Remote: {sanitize_display(name)}", file=sys.stderr) |
| 1093 | print(f" URL: {sanitize_display(url)}", file=sys.stderr) |
| 1094 | print(f" Server: {sanitize_display(server_root)}", file=sys.stderr) |
| 1095 | print(f" Ping: {status_icon} {sanitize_display(message)}", file=sys.stderr) |
| 1096 | if tracked_refs: |
| 1097 | print(" Tracked refs (from last fetch/push):", file=sys.stderr) |
| 1098 | for branch, sha in sorted(tracked_refs.items()): |
| 1099 | print( |
| 1100 | f" {sanitize_display(name)}/{sanitize_display(branch):<30} {sha}", |
| 1101 | file=sys.stderr, |
| 1102 | ) |
| 1103 | else: |
| 1104 | print(" Tracked refs: (none — run 'muse fetch' first)", file=sys.stderr) |
| 1105 | print("", file=sys.stderr) |
| 1106 | |
| 1107 | if not reachable: |
| 1108 | raise SystemExit(ExitCode.REMOTE_ERROR) |
File History
10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6
chore: bump version to 0.2.0.dev2 — nightly.2
Sonnet 4.6
patch
12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9
docs: add issue docs for push have-negotiation bug (#55) an…
Sonnet 4.6
18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74
chore: trigger push to surface null-OID paths
Sonnet 4.6
24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
56 days ago