remote.py
python
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
72 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 | } |
| 27 | |
| 28 | ``muse remote add|remove|rename|set-url [--json]``:: |
| 29 | |
| 30 | {"status": "ok", "name": "<name>", "url": "<url>|null", |
| 31 | "old_name": "<name>|null", "new_name": "<name>|null"} |
| 32 | |
| 33 | ``muse remote get-url [--json]``:: |
| 34 | |
| 35 | {"name": "<name>", "url": "<url>"} |
| 36 | |
| 37 | ``muse remote status [--json]``:: |
| 38 | |
| 39 | {"remote": "<name>", "url": "<url>", "server_root": "<url>", |
| 40 | "reachable": true|false, "http_status": <N>|null, |
| 41 | "message": "<msg>", "tracked_refs": {"<branch>": "<sha8>"}} |
| 42 | |
| 43 | Exit codes |
| 44 | ---------- |
| 45 | |
| 46 | 0 — success |
| 47 | 1 — user error (unknown remote, duplicate remote, invalid URL scheme, invalid name) |
| 48 | 2 — not inside a Muse repository |
| 49 | 5 — remote unreachable (status subcommand) |
| 50 | """ |
| 51 | |
| 52 | from __future__ import annotations |
| 53 | |
| 54 | import argparse |
| 55 | import json |
| 56 | import logging |
| 57 | import sys |
| 58 | import urllib.error |
| 59 | import urllib.request |
| 60 | from typing import TYPE_CHECKING, TypedDict |
| 61 | from urllib.parse import urlparse |
| 62 | |
| 63 | from muse.cli.config import ( |
| 64 | get_remote, |
| 65 | get_remote_head, |
| 66 | get_upstream, |
| 67 | list_remotes, |
| 68 | remove_remote, |
| 69 | rename_remote, |
| 70 | set_remote, |
| 71 | ) |
| 72 | from muse.core.errors import ExitCode |
| 73 | from muse.core.repo import require_repo |
| 74 | from muse.core.validation import sanitize_display |
| 75 | |
| 76 | if TYPE_CHECKING: |
| 77 | import pathlib |
| 78 | |
| 79 | type _RefMap = dict[str, str] |
| 80 | |
| 81 | logger = logging.getLogger(__name__) |
| 82 | |
| 83 | # Remote name: alphanumeric, dash, underscore, dot. No slashes, no spaces. |
| 84 | _VALID_REMOTE_NAME_CHARS = frozenset( |
| 85 | "abcdefghijklmnopqrstuvwxyz" |
| 86 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" |
| 87 | "0123456789-_." |
| 88 | ) |
| 89 | # Only allow http and https — no file://, ftp://, data://, etc. |
| 90 | _ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) |
| 91 | # Prevent unbounded writes to config.toml. |
| 92 | _MAX_REMOTE_NAME_LEN = 100 |
| 93 | _MAX_URL_LEN = 2048 |
| 94 | |
| 95 | |
| 96 | # ── TypedDicts ──────────────────────────────────────────────────────────────── |
| 97 | |
| 98 | class _RemoteEntryJson(TypedDict): |
| 99 | """Single remote entry in ``muse remote --json`` list output.""" |
| 100 | |
| 101 | name: str |
| 102 | url: str |
| 103 | tracking: str # "<name>/<upstream_branch>" or empty |
| 104 | head: str # last-known HEAD sha8 or empty |
| 105 | |
| 106 | |
| 107 | class _RemoteListJson(TypedDict): |
| 108 | """JSON schema for ``muse remote [--json]``.""" |
| 109 | |
| 110 | remotes: list[_RemoteEntryJson] |
| 111 | |
| 112 | |
| 113 | class _RemoteMutationJson(TypedDict): |
| 114 | """JSON schema for add / remove / rename / set-url subcommands.""" |
| 115 | |
| 116 | status: str # "ok" | "error" |
| 117 | name: str # primary remote name (new name for rename) |
| 118 | url: str | None # applicable URL, null for remove/rename |
| 119 | old_url: str | None # set-url only: the URL before the update |
| 120 | old_name: str | None # rename only |
| 121 | new_name: str | None # rename only |
| 122 | |
| 123 | |
| 124 | class _RemoteGetUrlJson(TypedDict): |
| 125 | """JSON schema for ``muse remote get-url``.""" |
| 126 | |
| 127 | name: str |
| 128 | url: str |
| 129 | |
| 130 | |
| 131 | class _RemoteStatusJson(TypedDict): |
| 132 | """JSON schema for ``muse remote status``.""" |
| 133 | |
| 134 | remote: str |
| 135 | url: str |
| 136 | server_root: str |
| 137 | reachable: bool |
| 138 | http_status: int | None |
| 139 | message: str |
| 140 | tracked_refs: _RefMap |
| 141 | |
| 142 | |
| 143 | # ── Validation helpers ──────────────────────────────────────────────────────── |
| 144 | |
| 145 | def _validate_remote_name(name: str) -> str | None: |
| 146 | """Return an error message if *name* is not a valid remote name, else None.""" |
| 147 | if not name: |
| 148 | return "Remote name must not be empty." |
| 149 | if len(name) > _MAX_REMOTE_NAME_LEN: |
| 150 | return f"Remote name is too long ({len(name)} chars); maximum is {_MAX_REMOTE_NAME_LEN}." |
| 151 | invalid = {c for c in name if c not in _VALID_REMOTE_NAME_CHARS} |
| 152 | if invalid: |
| 153 | shown = ", ".join(repr(c) for c in sorted(invalid)) |
| 154 | return f"Remote name contains invalid characters: {shown}" |
| 155 | return None |
| 156 | |
| 157 | |
| 158 | def _validate_url_scheme(url: str) -> str | None: |
| 159 | """Return an error message if *url* does not use an allowed scheme, else None.""" |
| 160 | scheme = urlparse(url).scheme.lower() |
| 161 | if scheme not in _ALLOWED_URL_SCHEMES: |
| 162 | allowed = ", ".join(sorted(_ALLOWED_URL_SCHEMES)) |
| 163 | return f"URL scheme '{sanitize_display(scheme)}' is not allowed. Use one of: {allowed}" |
| 164 | return None |
| 165 | |
| 166 | |
| 167 | def _collect_tracked_refs(remotes_dir: "pathlib.Path") -> _RefMap: |
| 168 | """Walk *remotes_dir* recursively and return branch → sha8 mapping. |
| 169 | |
| 170 | Handles nested branch names (e.g. ``feat/ui`` stored as |
| 171 | ``remotes_dir/feat/ui``). Symlinks are skipped to prevent path-traversal |
| 172 | attacks — the same guard applied in ``muse fetch``. |
| 173 | """ |
| 174 | refs: _RefMap = {} |
| 175 | if not remotes_dir.exists(): |
| 176 | return refs |
| 177 | _walk_refs(remotes_dir, remotes_dir, refs) |
| 178 | return refs |
| 179 | |
| 180 | |
| 181 | def _walk_refs(base: "pathlib.Path", current: "pathlib.Path", acc: _RefMap) -> None: |
| 182 | """Recursively populate *acc* with branch_name → sha8 from *current*.""" |
| 183 | for entry in sorted(current.iterdir()): |
| 184 | if entry.is_symlink(): |
| 185 | logger.debug("⚠️ Skipping symlink in remotes dir: %s", entry) |
| 186 | continue |
| 187 | if entry.is_dir(): |
| 188 | _walk_refs(base, entry, acc) |
| 189 | elif entry.is_file(): |
| 190 | branch = str(entry.relative_to(base)) |
| 191 | sha = entry.read_text().strip() |
| 192 | acc[branch] = sha[:8] if sha else "(empty)" |
| 193 | |
| 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; success JSON or plain |
| 202 | output goes to stdout. |
| 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", |
| 217 | action="store_true", |
| 218 | dest="json_output", |
| 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_output", 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://...\"}\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_output", 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_output", 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_output", 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_output", 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\n" |
| 407 | "Exit codes:\n" |
| 408 | " 0 Remote is reachable\n" |
| 409 | " 1 Invalid remote name, or remote does not exist\n" |
| 410 | " 2 Not inside a Muse repository\n" |
| 411 | " 5 Remote is unreachable (network error, timeout, or non-2xx response)" |
| 412 | ), |
| 413 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 414 | ) |
| 415 | status_p.add_argument("name", help="Remote name.") |
| 416 | status_p.add_argument( |
| 417 | "--json", "-j", action="store_true", dest="json_output", default=False, |
| 418 | help="Emit JSON to stdout instead of human-readable output.", |
| 419 | ) |
| 420 | status_p.add_argument( |
| 421 | "--timeout", dest="timeout", type=float, default=6.0, |
| 422 | help="HTTP connect timeout in seconds (default: 6).", |
| 423 | ) |
| 424 | status_p.set_defaults(func=run_status) |
| 425 | |
| 426 | parser.set_defaults(func=run) |
| 427 | |
| 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 | verbose: bool = args.verbose |
| 440 | json_output: bool = args.json_output |
| 441 | |
| 442 | root = require_repo() |
| 443 | remotes = list_remotes(root) |
| 444 | |
| 445 | if json_output: |
| 446 | entries: list[_RemoteEntryJson] = [] |
| 447 | for r in remotes: |
| 448 | upstream = get_upstream(r["name"], root) |
| 449 | head = get_remote_head(r["name"], upstream or "main", root) if upstream else None |
| 450 | entries.append({ |
| 451 | "name": r["name"], |
| 452 | "url": r["url"], |
| 453 | "tracking": f"{r['name']}/{upstream}" if upstream else "", |
| 454 | "head": head[:8] if head else "", |
| 455 | }) |
| 456 | out: _RemoteListJson = {"remotes": entries} |
| 457 | print(json.dumps(out)) |
| 458 | return |
| 459 | |
| 460 | if not remotes: |
| 461 | print("No remotes configured. Use 'muse remote add <name> <url>'.", file=sys.stderr) |
| 462 | return |
| 463 | |
| 464 | name_width = max(len(r["name"]) for r in remotes) |
| 465 | for r in remotes: |
| 466 | if verbose: |
| 467 | upstream = get_upstream(r["name"], root) |
| 468 | head = get_remote_head(r["name"], upstream or "main", root) |
| 469 | head_str = f" @ {head[:8]}" if head else "" |
| 470 | tracking = f" -> {r['name']}/{upstream}" if upstream else "" |
| 471 | label = f"{r['name']:<{name_width}}" |
| 472 | print(f"{sanitize_display(label)}\t{sanitize_display(r['url'])}{tracking}{head_str} (fetch)") |
| 473 | print(f"{sanitize_display(label)}\t{sanitize_display(r['url'])}{tracking}{head_str} (push)") |
| 474 | else: |
| 475 | print(r["name"]) |
| 476 | |
| 477 | |
| 478 | # ── add ─────────────────────────────────────────────────────────────────────── |
| 479 | |
| 480 | def run_add(args: argparse.Namespace) -> None: |
| 481 | """Register a new remote repository connection. |
| 482 | |
| 483 | Validates all inputs before any write: |
| 484 | |
| 485 | - Remote name: alphanumeric + ``-_.``, no slashes/spaces/control chars, |
| 486 | max :data:`_MAX_REMOTE_NAME_LEN` characters. |
| 487 | - URL: ``http`` or ``https`` scheme only; leading/trailing whitespace is |
| 488 | stripped before validation so pasted URLs with trailing newlines work |
| 489 | correctly. Max :data:`_MAX_URL_LEN` characters. |
| 490 | - Duplicate check: exits with a hint to use ``muse remote set-url`` when |
| 491 | the remote already exists. |
| 492 | |
| 493 | Diagnostics go to stderr; ``--json`` emits :class:`_RemoteMutationJson` |
| 494 | to stdout. |
| 495 | |
| 496 | Exit codes: |
| 497 | 0 Remote written to ``.muse/config.toml``. |
| 498 | 1 Invalid name, invalid/oversized URL, or remote already exists. |
| 499 | 2 Not inside a Muse repository. |
| 500 | """ |
| 501 | name: str = args.name |
| 502 | url: str = args.url.strip() # strip whitespace — pasted URLs often have trailing newlines |
| 503 | json_output: bool = args.json_output |
| 504 | |
| 505 | if err := _validate_remote_name(name): |
| 506 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 507 | raise SystemExit(ExitCode.USER_ERROR) |
| 508 | |
| 509 | if len(url) > _MAX_URL_LEN: |
| 510 | print( |
| 511 | f"❌ URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}.", |
| 512 | file=sys.stderr, |
| 513 | ) |
| 514 | raise SystemExit(ExitCode.USER_ERROR) |
| 515 | |
| 516 | if err := _validate_url_scheme(url): |
| 517 | print(f"❌ {err}", file=sys.stderr) |
| 518 | raise SystemExit(ExitCode.USER_ERROR) |
| 519 | |
| 520 | root = require_repo() |
| 521 | existing = get_remote(name, root) |
| 522 | if existing is not None: |
| 523 | print( |
| 524 | f"❌ Remote '{sanitize_display(name)}' already exists: {sanitize_display(existing)}", |
| 525 | file=sys.stderr, |
| 526 | ) |
| 527 | print( |
| 528 | f" Use 'muse remote set-url {sanitize_display(name)} <url>' to update it.", |
| 529 | file=sys.stderr, |
| 530 | ) |
| 531 | raise SystemExit(ExitCode.USER_ERROR) |
| 532 | |
| 533 | set_remote(name, url, root) |
| 534 | |
| 535 | if json_output: |
| 536 | result: _RemoteMutationJson = { |
| 537 | "status": "ok", |
| 538 | "name": name, |
| 539 | "url": url, |
| 540 | "old_url": None, |
| 541 | "old_name": None, |
| 542 | "new_name": None, |
| 543 | } |
| 544 | print(json.dumps(result)) |
| 545 | else: |
| 546 | print(f"✅ Remote '{sanitize_display(name)}' added: {sanitize_display(url)}", file=sys.stderr) |
| 547 | |
| 548 | |
| 549 | # ── remove ──────────────────────────────────────────────────────────────────── |
| 550 | |
| 551 | def run_remove(args: argparse.Namespace) -> None: |
| 552 | """Remove a remote and all its tracking refs. |
| 553 | |
| 554 | Validates the remote name format first (same rules as ``muse remote add``) |
| 555 | so invalid-looking names produce a clear format error rather than a |
| 556 | misleading "does not exist" message. |
| 557 | |
| 558 | The removed URL is captured before deletion and included in the |
| 559 | ``--json`` response, giving agents enough information to confirm the |
| 560 | correct remote was removed or to undo the operation with |
| 561 | ``muse remote add``. |
| 562 | |
| 563 | The tracking refs directory (``.muse/remotes/<name>/``) is removed with |
| 564 | ``shutil.rmtree`` if present. If that path is a symlink the deletion is |
| 565 | skipped and a warning is logged — following a symlink could delete files |
| 566 | outside the repository tree. |
| 567 | |
| 568 | Exit codes: |
| 569 | 0 Remote removed from config and tracking refs cleaned up. |
| 570 | 1 Invalid name, or remote does not exist. |
| 571 | 2 Not inside a Muse repository. |
| 572 | """ |
| 573 | name: str = args.name |
| 574 | json_output: bool = args.json_output |
| 575 | |
| 576 | if err := _validate_remote_name(name): |
| 577 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 578 | raise SystemExit(ExitCode.USER_ERROR) |
| 579 | |
| 580 | root = require_repo() |
| 581 | |
| 582 | # Capture the URL before removal so it can be returned in JSON output. |
| 583 | removed_url: str | None = get_remote(name, root) |
| 584 | if removed_url is None: |
| 585 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 586 | raise SystemExit(ExitCode.USER_ERROR) |
| 587 | |
| 588 | try: |
| 589 | remove_remote(name, root) |
| 590 | except KeyError: |
| 591 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 592 | raise SystemExit(ExitCode.USER_ERROR) |
| 593 | |
| 594 | if json_output: |
| 595 | result: _RemoteMutationJson = { |
| 596 | "status": "ok", |
| 597 | "name": name, |
| 598 | "url": removed_url, |
| 599 | "old_url": None, |
| 600 | "old_name": None, |
| 601 | "new_name": None, |
| 602 | } |
| 603 | print(json.dumps(result)) |
| 604 | else: |
| 605 | print(f"✅ Remote '{sanitize_display(name)}' removed.", file=sys.stderr) |
| 606 | |
| 607 | |
| 608 | # ── rename ──────────────────────────────────────────────────────────────────── |
| 609 | |
| 610 | def run_rename(args: argparse.Namespace) -> None: |
| 611 | """Rename a remote and move its tracking refs. |
| 612 | |
| 613 | Both *old_name* and *new_name* are validated against remote name rules |
| 614 | before any repo or filesystem access, so invalid-looking names produce a |
| 615 | clear format error rather than a confusing "does not exist" message. |
| 616 | |
| 617 | The URL is looked up before the rename and included in the ``--json`` |
| 618 | response so agents can verify which remote was renamed. |
| 619 | |
| 620 | The tracking refs directory (``.muse/remotes/<old_name>/``) is moved to |
| 621 | ``.muse/remotes/<new_name>/`` via an atomic ``os.rename`` if it exists. |
| 622 | |
| 623 | Exit codes: |
| 624 | 0 Remote renamed in config and tracking refs moved. |
| 625 | 1 Invalid name, old remote does not exist, or new name already taken. |
| 626 | 2 Not inside a Muse repository. |
| 627 | """ |
| 628 | old_name: str = args.old_name |
| 629 | new_name: str = args.new_name |
| 630 | json_output: bool = args.json_output |
| 631 | |
| 632 | if err := _validate_remote_name(old_name): |
| 633 | print(f"❌ Invalid remote name '{sanitize_display(old_name)}': {err}", file=sys.stderr) |
| 634 | raise SystemExit(ExitCode.USER_ERROR) |
| 635 | |
| 636 | if err := _validate_remote_name(new_name): |
| 637 | print(f"❌ Invalid remote name '{sanitize_display(new_name)}': {err}", file=sys.stderr) |
| 638 | raise SystemExit(ExitCode.USER_ERROR) |
| 639 | |
| 640 | root = require_repo() |
| 641 | |
| 642 | # Capture the URL before renaming so it can be returned in JSON output. |
| 643 | renamed_url: str | None = get_remote(old_name, root) |
| 644 | |
| 645 | try: |
| 646 | rename_remote(old_name, new_name, root) |
| 647 | except KeyError: |
| 648 | print(f"❌ Remote '{sanitize_display(old_name)}' does not exist.", file=sys.stderr) |
| 649 | raise SystemExit(ExitCode.USER_ERROR) |
| 650 | except ValueError: |
| 651 | print(f"❌ Remote '{sanitize_display(new_name)}' already exists.", file=sys.stderr) |
| 652 | raise SystemExit(ExitCode.USER_ERROR) |
| 653 | |
| 654 | if json_output: |
| 655 | result: _RemoteMutationJson = { |
| 656 | "status": "ok", |
| 657 | "name": new_name, |
| 658 | "url": renamed_url, |
| 659 | "old_url": None, |
| 660 | "old_name": old_name, |
| 661 | "new_name": new_name, |
| 662 | } |
| 663 | print(json.dumps(result)) |
| 664 | else: |
| 665 | print( |
| 666 | f"✅ Remote '{sanitize_display(old_name)}' renamed to '{sanitize_display(new_name)}'.", |
| 667 | file=sys.stderr, |
| 668 | ) |
| 669 | |
| 670 | |
| 671 | # ── get-url ─────────────────────────────────────────────────────────────────── |
| 672 | |
| 673 | def run_get_url(args: argparse.Namespace) -> None: |
| 674 | """Print the URL of a remote. |
| 675 | |
| 676 | Validates the remote name format before any repo or filesystem access so |
| 677 | an invalid-looking name produces a clear format error rather than a |
| 678 | misleading "does not exist" message. |
| 679 | |
| 680 | In text mode the bare URL is printed to stdout via |
| 681 | :func:`~muse.core.validation.sanitize_display` so ANSI escape codes that |
| 682 | might have been placed in ``config.toml`` by direct editing cannot inject |
| 683 | terminal control sequences. For shell composition the sanitized URL is |
| 684 | virtually always identical to the stored one — valid URLs contain no ANSI. |
| 685 | |
| 686 | In JSON mode a :class:`_RemoteGetUrlJson` object is emitted to stdout; |
| 687 | JSON string encoding neutralises any control characters in the value. |
| 688 | |
| 689 | Exit codes: |
| 690 | 0 URL printed to stdout. |
| 691 | 1 Invalid remote name, or remote does not exist. |
| 692 | 2 Not inside a Muse repository. |
| 693 | """ |
| 694 | name: str = args.name |
| 695 | json_output: bool = args.json_output |
| 696 | |
| 697 | if err := _validate_remote_name(name): |
| 698 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 699 | raise SystemExit(ExitCode.USER_ERROR) |
| 700 | |
| 701 | root = require_repo() |
| 702 | url = get_remote(name, root) |
| 703 | if url is None: |
| 704 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 705 | raise SystemExit(ExitCode.USER_ERROR) |
| 706 | |
| 707 | if json_output: |
| 708 | out: _RemoteGetUrlJson = {"name": name, "url": url} |
| 709 | print(json.dumps(out)) |
| 710 | else: |
| 711 | # Bare URL on stdout — intended for shell composition: $(muse remote get-url origin) |
| 712 | # sanitize_display strips ANSI/control chars that might appear in a hand-edited config. |
| 713 | print(sanitize_display(url)) |
| 714 | |
| 715 | |
| 716 | # ── set-url ─────────────────────────────────────────────────────────────────── |
| 717 | |
| 718 | def run_set_url(args: argparse.Namespace) -> None: |
| 719 | """Update the URL of an existing remote. |
| 720 | |
| 721 | Validates all inputs before any write: |
| 722 | |
| 723 | - Remote name: alphanumeric + ``-_.``, no slashes/spaces/control chars, |
| 724 | max :data:`_MAX_REMOTE_NAME_LEN` characters. Validated before |
| 725 | :func:`~muse.core.repo.require_repo` so invalid-looking names produce a |
| 726 | clear format error rather than a "does not exist" message. |
| 727 | - URL: ``http`` or ``https`` scheme only; leading/trailing whitespace is |
| 728 | stripped before validation so pasted URLs with trailing newlines work |
| 729 | correctly. Max :data:`_MAX_URL_LEN` characters. |
| 730 | |
| 731 | The previous URL is captured before the write and included in the |
| 732 | ``--json`` response as ``old_url``, giving agents enough information to |
| 733 | confirm the correct remote was updated or to undo the operation with |
| 734 | another ``muse remote set-url``. |
| 735 | |
| 736 | Diagnostics go to stderr; ``--json`` emits :class:`_RemoteMutationJson` |
| 737 | to stdout. |
| 738 | |
| 739 | Exit codes: |
| 740 | 0 URL updated in ``.muse/config.toml``. |
| 741 | 1 Invalid name, invalid/oversized URL, or remote does not exist. |
| 742 | 2 Not inside a Muse repository. |
| 743 | """ |
| 744 | name: str = args.name |
| 745 | url: str = args.url.strip() # strip whitespace — pasted URLs often have trailing newlines |
| 746 | json_output: bool = args.json_output |
| 747 | |
| 748 | if err := _validate_remote_name(name): |
| 749 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 750 | raise SystemExit(ExitCode.USER_ERROR) |
| 751 | |
| 752 | if len(url) > _MAX_URL_LEN: |
| 753 | print( |
| 754 | f"❌ URL is too long ({len(url)} chars); maximum is {_MAX_URL_LEN}.", |
| 755 | file=sys.stderr, |
| 756 | ) |
| 757 | raise SystemExit(ExitCode.USER_ERROR) |
| 758 | |
| 759 | if err := _validate_url_scheme(url): |
| 760 | print(f"❌ {err}", file=sys.stderr) |
| 761 | raise SystemExit(ExitCode.USER_ERROR) |
| 762 | |
| 763 | root = require_repo() |
| 764 | old_url = get_remote(name, root) |
| 765 | if old_url is None: |
| 766 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 767 | print( |
| 768 | f" Use 'muse remote add {sanitize_display(name)} <url>' to create it.", |
| 769 | file=sys.stderr, |
| 770 | ) |
| 771 | raise SystemExit(ExitCode.USER_ERROR) |
| 772 | |
| 773 | set_remote(name, url, root) |
| 774 | |
| 775 | if json_output: |
| 776 | result: _RemoteMutationJson = { |
| 777 | "status": "ok", |
| 778 | "name": name, |
| 779 | "url": url, |
| 780 | "old_url": old_url, |
| 781 | "old_name": None, |
| 782 | "new_name": None, |
| 783 | } |
| 784 | print(json.dumps(result)) |
| 785 | else: |
| 786 | print( |
| 787 | f"✅ Remote '{sanitize_display(name)}' URL updated: {sanitize_display(url)}", |
| 788 | file=sys.stderr, |
| 789 | ) |
| 790 | |
| 791 | |
| 792 | # ── _ping_url ───────────────────────────────────────────────────────────────── |
| 793 | |
| 794 | def _ping_url(base_url: str, timeout: float) -> tuple[bool, int | None, str]: |
| 795 | """Ping ``<base_url>/health``. |
| 796 | |
| 797 | Returns ``(reachable, http_status, message)``. |
| 798 | |
| 799 | Only ``http`` and ``https`` schemes are accepted — any other scheme is |
| 800 | treated as unreachable without making a network request to prevent SSRF |
| 801 | via ``file://`` or ``ftp://`` URLs stored in config. |
| 802 | """ |
| 803 | scheme = urlparse(base_url).scheme.lower() |
| 804 | if scheme not in _ALLOWED_URL_SCHEMES: |
| 805 | return False, None, f"Unsupported URL scheme '{sanitize_display(scheme)}'" |
| 806 | |
| 807 | health_url = base_url.rstrip("/") + "/health" |
| 808 | try: |
| 809 | req = urllib.request.Request(health_url, method="GET") |
| 810 | with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 |
| 811 | return True, resp.status, f"HTTP {resp.status} OK" |
| 812 | except urllib.error.HTTPError as exc: |
| 813 | return False, exc.code, f"HTTP {exc.code} {exc.reason}" |
| 814 | except urllib.error.URLError as exc: |
| 815 | return False, None, str(exc.reason) |
| 816 | except TimeoutError: |
| 817 | return False, None, f"timed out after {timeout}s" |
| 818 | except OSError as exc: |
| 819 | return False, None, str(exc) |
| 820 | |
| 821 | |
| 822 | # ── status ──────────────────────────────────────────────────────────────────── |
| 823 | |
| 824 | def run_status(args: argparse.Namespace) -> None: |
| 825 | """Check reachability and last-known tracking refs for a remote. |
| 826 | |
| 827 | This command is **read-only** — it does not fetch, write, or modify any |
| 828 | local state. Use it to inspect a remote before running ``muse push`` or |
| 829 | ``muse fetch``:: |
| 830 | |
| 831 | muse remote status origin |
| 832 | muse remote status origin --json |
| 833 | muse remote status origin --json --timeout 10 |
| 834 | |
| 835 | Validates the remote name format before any repo or filesystem access so |
| 836 | invalid-looking names produce a clear format error rather than a misleading |
| 837 | "does not exist" message. |
| 838 | |
| 839 | Pings ``<server_root>/health`` (derived from the stored URL) and reports |
| 840 | locally cached tracking data from previous fetch/push operations. The |
| 841 | tracking data is only as current as the last ``muse fetch``. |
| 842 | |
| 843 | Tracking refs are collected recursively so that nested branch names like |
| 844 | ``feat/ui`` (stored as ``remotes/origin/feat/ui``) are shown correctly. |
| 845 | Symlinks inside the remotes directory are skipped to prevent path traversal. |
| 846 | |
| 847 | JSON output (``--json``) goes to stdout; all human-readable text goes to |
| 848 | stderr. |
| 849 | |
| 850 | Exit codes: |
| 851 | 0 Remote reachable and status printed. |
| 852 | 1 Invalid remote name, or remote does not exist. |
| 853 | 2 Not inside a Muse repository. |
| 854 | 5 Remote is unreachable (network error, timeout, or non-2xx response). |
| 855 | """ |
| 856 | name: str = args.name |
| 857 | json_output: bool = args.json_output |
| 858 | timeout: float = args.timeout |
| 859 | |
| 860 | if err := _validate_remote_name(name): |
| 861 | print(f"❌ Invalid remote name '{sanitize_display(name)}': {err}", file=sys.stderr) |
| 862 | raise SystemExit(ExitCode.USER_ERROR) |
| 863 | |
| 864 | root = require_repo() |
| 865 | url = get_remote(name, root) |
| 866 | if url is None: |
| 867 | print(f"❌ Remote '{sanitize_display(name)}' does not exist.", file=sys.stderr) |
| 868 | raise SystemExit(ExitCode.USER_ERROR) |
| 869 | |
| 870 | # Extract the server root URL: http://host[:port]/owner/repo → http://host[:port] |
| 871 | parsed = urlparse(url) |
| 872 | server_root = f"{parsed.scheme}://{parsed.netloc}" |
| 873 | |
| 874 | reachable, http_status, message = _ping_url(server_root, timeout) |
| 875 | |
| 876 | # Recursively collect tracking refs — handles nested branch names like |
| 877 | # "feat/ui" and skips symlinks (path-traversal guard). |
| 878 | remotes_dir = root / ".muse" / "remotes" / name |
| 879 | tracked_refs = _collect_tracked_refs(remotes_dir) |
| 880 | |
| 881 | if json_output: |
| 882 | out: _RemoteStatusJson = { |
| 883 | "remote": name, |
| 884 | "url": url, |
| 885 | "server_root": server_root, |
| 886 | "reachable": reachable, |
| 887 | "http_status": http_status, |
| 888 | "message": message, |
| 889 | "tracked_refs": tracked_refs, |
| 890 | } |
| 891 | print(json.dumps(out)) |
| 892 | else: |
| 893 | status_icon = "✅" if reachable else "❌" |
| 894 | print(f"\n Remote: {sanitize_display(name)}", file=sys.stderr) |
| 895 | print(f" URL: {sanitize_display(url)}", file=sys.stderr) |
| 896 | print(f" Server: {sanitize_display(server_root)}", file=sys.stderr) |
| 897 | print(f" Ping: {status_icon} {sanitize_display(message)}", file=sys.stderr) |
| 898 | if tracked_refs: |
| 899 | print(" Tracked refs (from last fetch/push):", file=sys.stderr) |
| 900 | for branch, sha in sorted(tracked_refs.items()): |
| 901 | print( |
| 902 | f" {sanitize_display(name)}/{sanitize_display(branch):<30} {sha}", |
| 903 | file=sys.stderr, |
| 904 | ) |
| 905 | else: |
| 906 | print(" Tracked refs: (none — run 'muse fetch' first)", file=sys.stderr) |
| 907 | print("", file=sys.stderr) |
| 908 | |
| 909 | if not reachable: |
| 910 | raise SystemExit(ExitCode.REMOTE_ERROR) |
File History
1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
72 days ago