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