remote.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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)