gabriel / muse public
remote.py python
910 lines 37.7 KB
Raw
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