gabriel / muse public
clone.py python
522 lines 17.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
1 """muse clone — create a local copy of a remote Muse repository.
2
3 Downloads the complete commit history, snapshots, and objects from a remote
4 MuseHub repository into a new local directory. After cloning:
5
6 - A full ``.muse/`` directory is created with the remote's repo_id and domain.
7 - The ``origin`` remote is configured to point at the source URL.
8 - The default branch is checked out into the working tree.
9
10 Usage
11 -----
12
13 muse clone <url> Clone into a directory named after the last URL segment.
14 muse clone <url> <dir> Clone into a specific directory.
15 muse clone <url> --branch dev Clone and check out 'dev'.
16 muse clone <url> --dry-run Show what would happen without writing anything.
17 muse clone <url> --no-checkout Skip working-tree restore after cloning.
18 muse clone <url> --json Emit a machine-readable JSON result to stdout.
19
20 Auth
21 ----
22
23 Signing identities are read from ``~/.muse/identity.toml`` keyed by hostname.
24 No signing identity is required for public repositories.
25
26 JSON schema (``--json``)
27 ------------------------
28
29 ::
30
31 {
32 "status": "cloned | dry_run | already_exists",
33 "url": "<remote_url>",
34 "directory": "<local_path>",
35 "branch": "<branch_checked_out>",
36 "commits_received": <N>,
37 "objects_written": <N>,
38 "head": "<sha256> | null",
39 "domain": "<domain>",
40 "dry_run": false
41 }
42
43 Exit codes
44 ----------
45
46 0 — success (including dry-run)
47 1 — user error (target already exists, empty repository, unknown branch)
48 2 — internal / transport error
49 """
50
51 from __future__ import annotations
52
53 import argparse
54 import json
55 import logging
56 import pathlib
57 import shutil
58 import sys
59 from typing import TYPE_CHECKING, TypedDict
60
61 import time
62
63 from muse._version import __version__ as _SCHEMA_VERSION
64 from muse.core.timing import start_timer
65 from muse.core.envelope import EnvelopeJson, make_envelope
66 from muse.cli.config import get_signing_identity, set_remote, set_remote_head, set_upstream
67 from muse.core._types import content_hash, now_utc_iso, short_id
68 from muse.core.paths import muse_dir as _muse_dir, ref_path as _ref_path
69 from muse.core.errors import ExitCode
70 from muse.core.object_store import write_object
71 from muse.core.pack import ObjectPayload, apply_mpack
72 from muse.core.store import (
73 read_commit,
74 read_snapshot,
75 write_head_branch,
76 write_text_atomic,
77 )
78 from muse.core.transport import TransportError, make_transport
79 from muse.core.validation import sanitize_display
80 from muse.core.workdir import apply_manifest
81
82
83 type _RepoMeta = dict[str, str]
84 if TYPE_CHECKING:
85 from muse.core.pack import ApplyResult
86
87 logger = logging.getLogger(__name__)
88
89 # Canonical set of subdirectories — must match muse init's _INIT_SUBDIRS.
90 _CLONE_SUBDIRS: tuple[str, ...] = (
91 "refs",
92 "refs/heads",
93 "objects",
94 "commits",
95 "snapshots",
96 "tags",
97 )
98
99 _DEFAULT_CONFIG = """\
100 [user]
101 name = ""
102 email = ""
103
104 [remotes]
105
106 [domain]
107 # Domain-specific configuration keys depend on the active domain.
108 """
109
110
111 class _CloneJson(EnvelopeJson):
112 """Stable JSON schema emitted by ``muse clone --json``."""
113
114 status: str # "cloned" | "dry_run" | "already_exists"
115 url: str
116 directory: str # resolved local path
117 branch: str # branch checked out
118 commits_received: int
119 objects_written: int
120 head: str | None # HEAD commit ID after clone, null on dry-run
121 domain: str
122 dry_run: bool
123
124
125 class _CloneErrorJson(EnvelopeJson):
126 """JSON output for clone transport/network error paths."""
127
128 error: str # "remote_unreachable" | "empty_repository" | "fetch_failed"
129 url: str
130 message: str
131
132
133 def _infer_dir_name(url: str) -> str:
134 """Derive a safe local directory name from the last non-empty segment of *url*.
135
136 Strips query strings, fragments, and path-traversal components so that a
137 crafted URL like ``http://evil.example.com/../../../../etc`` cannot escape
138 the current working directory.
139 """
140 # Drop fragment and query before splitting on path separators.
141 stripped = url.split("#")[0].split("?")[0].rstrip("/")
142 last = stripped.rsplit("/", 1)[-1]
143 # pathlib.Path.name always strips leading dots and directory separators,
144 # eliminating traversal attempts like ".." or "../../secret".
145 safe = pathlib.PurePosixPath(last).name
146 return safe if safe and safe not in (".", "..") else "muse-repo"
147
148
149 def _init_muse_dir(
150 target: pathlib.Path,
151 repo_id: str,
152 domain: str,
153 default_branch: str,
154 ) -> None:
155 """Create the ``.muse/`` directory tree inside *target*.
156
157 Uses the same subdirectory set as ``muse init`` so that every command that
158 relies on the standard layout (tags, objects, etc.) works out of the box.
159 """
160 muse_dir = _muse_dir(target)
161 for subdir in _CLONE_SUBDIRS:
162 (muse_dir / subdir).mkdir(parents=True, exist_ok=True)
163
164 repo_meta: _RepoMeta = {
165 "repo_id": repo_id,
166 "schema_version": _SCHEMA_VERSION,
167 "created_at": now_utc_iso(),
168 "domain": domain,
169 }
170 write_text_atomic(muse_dir / "repo.json", f"{json.dumps(repo_meta)}\n")
171 write_head_branch(muse_dir.parent, default_branch)
172 write_text_atomic(_ref_path(target, default_branch), "")
173 write_text_atomic(muse_dir / "config.toml", _DEFAULT_CONFIG)
174
175
176 def _restore_working_tree(root: pathlib.Path, commit_id: str) -> None:
177 """Restore the working tree to the snapshot referenced by *commit_id*.
178
179 Logs a warning to stderr (rather than silently returning) if the commit or
180 snapshot cannot be read — this surfaces bugs where apply_mpack did not write
181 the expected objects.
182 """
183 commit = read_commit(root, commit_id)
184 if commit is None:
185 logger.warning(
186 "⚠️ clone: commit %s not found after apply_mpack — working tree not restored",
187 short_id(commit_id),
188 )
189 return
190 snap = read_snapshot(root, commit.snapshot_id)
191 if snap is None:
192 logger.warning(
193 "⚠️ clone: snapshot %s not found after apply_mpack — working tree not restored",
194 short_id(commit.snapshot_id),
195 )
196 return
197 apply_manifest(root, {}, snap.manifest)
198
199
200 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
201 """Register the ``muse clone`` subcommand and all its flags."""
202 parser = subparsers.add_parser(
203 "clone",
204 help="Create a local copy of a remote Muse repository.",
205 description=__doc__,
206 formatter_class=argparse.RawDescriptionHelpFormatter,
207 )
208 parser.add_argument(
209 "url",
210 help="URL of the remote Muse repository to clone.",
211 )
212 parser.add_argument(
213 "directory",
214 nargs="?",
215 default=None,
216 help=(
217 "Local directory to clone into. "
218 "Defaults to the last path segment of the URL."
219 ),
220 )
221 parser.add_argument(
222 "--branch", "-b",
223 default=None,
224 help="Branch to check out after cloning (default: remote default branch).",
225 )
226 parser.add_argument(
227 "--dry-run", "-n",
228 action="store_true",
229 default=False,
230 dest="dry_run",
231 help=(
232 "Contact the remote and show what would be cloned without writing "
233 "any files or creating any directories."
234 ),
235 )
236 parser.add_argument(
237 "--no-checkout",
238 action="store_true",
239 default=False,
240 dest="no_checkout",
241 help="Skip restoring the working tree after cloning.",
242 )
243 parser.add_argument(
244 "--json", "-j",
245 action="store_true",
246 dest="json_out",
247 help="Emit JSON output to stdout.",
248 )
249 parser.set_defaults(func=run)
250
251
252 def run(args: argparse.Namespace) -> None:
253 """Clone a remote Muse repository into a new local directory.
254
255 Downloads the full commit history, snapshots, objects, and branch heads.
256 Configures ``origin`` remote and upstream tracking. Checks out the default
257 branch unless ``--no-checkout`` is given. On any error after the target
258 directory has been partially created the directory is removed to leave the
259 filesystem clean.
260
261 Agent quickstart
262 ----------------
263 ::
264
265 muse clone https://musehub.ai/gabriel/muse --json
266 muse clone https://musehub.ai/gabriel/muse --branch dev --json
267 muse clone https://musehub.ai/gabriel/muse mydir --dry-run --json
268
269 JSON fields
270 -----------
271 status ``"cloned"``, ``"already_exists"``, or ``"error"``.
272 url The remote URL cloned from.
273 directory Absolute path to the created local directory.
274 branch Branch checked out after cloning.
275 commits_received Number of commits received.
276 objects_written Number of content objects written.
277 head Full commit ID at the branch tip; ``null`` on error.
278 domain Repository domain (e.g. ``"code"``).
279 dry_run ``true`` when ``--dry-run`` was passed.
280
281 Exit codes
282 ----------
283 0 Clone completed successfully.
284 1 Target already exists or bad URL.
285 3 Network or internal error during fetch.
286 """
287 elapsed = start_timer()
288 url: str = args.url
289 directory: str | None = args.directory
290 branch: str | None = args.branch
291 dry_run: bool = args.dry_run
292 no_checkout: bool = args.no_checkout
293 json_out: bool = args.json_out
294
295 # clone does not need to be inside a Muse repo — it creates a new one.
296 # Resolve the target name and path before any network I/O.
297 target_name = directory or _infer_dir_name(url)
298 target = pathlib.Path.cwd() / target_name
299
300 if dry_run:
301 print("(dry run — no files will be created)", file=sys.stderr)
302
303 if _muse_dir(target).exists():
304 msg = f"❌ '{sanitize_display(str(target))}' is already a Muse repository."
305 print(msg, file=sys.stderr)
306 if json_out:
307 print(json.dumps(_CloneJson(
308 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
309 status="already_exists",
310 url=url,
311 directory=str(target),
312 branch=branch or "",
313 commits_received=0,
314 objects_written=0,
315 head=None,
316 domain="",
317 dry_run=dry_run,
318 )))
319 raise SystemExit(ExitCode.USER_ERROR)
320
321 signing = get_signing_identity(remote_url=url)
322
323 transport = make_transport(url)
324
325 print(
326 f"Cloning from {sanitize_display(url)} …",
327 file=sys.stderr,
328 )
329 try:
330 info = transport.fetch_remote_info(url, signing=signing)
331 except TransportError as exc:
332 if json_out:
333 print(json.dumps(_CloneErrorJson(
334 **make_envelope(elapsed, exit_code=ExitCode.INTERNAL_ERROR),
335 error="remote_unreachable",
336 url=url,
337 message=str(exc),
338 )))
339 print(f"❌ Cannot reach remote: {exc}", file=sys.stderr)
340 raise SystemExit(ExitCode.INTERNAL_ERROR)
341
342 # Use "code" as the domain fallback — "midi" was the first plugin but is
343 # not the canonical default domain for new repositories.
344 if info["repo_id"]:
345 remote_repo_id = info["repo_id"]
346 else:
347 _cloned_at = now_utc_iso()
348 remote_repo_id = content_hash({"cloned_at": _cloned_at, "url": url})
349 domain = info["domain"] or "code"
350 default_branch = branch or info["default_branch"] or "main"
351
352 if not info["branch_heads"]:
353 if json_out:
354 print(json.dumps(_CloneErrorJson(
355 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
356 error="empty_repository",
357 url=url,
358 message="remote repository has no branches",
359 )))
360 print(
361 "❌ Remote repository has no branches (empty repository).",
362 file=sys.stderr,
363 )
364 raise SystemExit(ExitCode.USER_ERROR)
365
366 default_commit_id = info["branch_heads"].get(default_branch)
367 if default_commit_id is None:
368 # Fall back to the first available branch rather than failing hard —
369 # a user who requests a non-existent branch gets a clear warning.
370 first_branch, default_commit_id = next(iter(info["branch_heads"].items()))
371 print(
372 f" ⚠️ Branch '{sanitize_display(default_branch)}' not found on remote; "
373 f"checking out '{sanitize_display(first_branch)}' instead.",
374 file=sys.stderr,
375 )
376 default_branch = first_branch
377
378 available = sorted(info["branch_heads"])
379 logger.debug(
380 "Remote has %d branch(es): %s",
381 len(available),
382 ", ".join(sanitize_display(b) for b in available),
383 )
384
385 # ── dry-run exits here — no filesystem changes after this point ──────────
386 if dry_run:
387 want_count = len(info["branch_heads"])
388 if json_out:
389 print(json.dumps(_CloneJson(
390 **make_envelope(elapsed),
391 status="dry_run",
392 url=url,
393 directory=str(target),
394 branch=default_branch,
395 commits_received=0,
396 objects_written=0,
397 head=default_commit_id,
398 domain=domain,
399 dry_run=True,
400 )))
401 else:
402 print(
403 f"Would clone {sanitize_display(url)} → {sanitize_display(str(target))}",
404 file=sys.stderr,
405 )
406 print(
407 f" branch={sanitize_display(default_branch)}, "
408 f"domain={sanitize_display(domain)}, "
409 f"{want_count} branch head(s) to fetch",
410 file=sys.stderr,
411 )
412 return
413
414 # ── real clone ────────────────────────────────────────────────────────────
415 target.mkdir(parents=True, exist_ok=True)
416 try:
417 _init_muse_dir(target, remote_repo_id, domain, default_branch)
418 except OSError as exc:
419 print(
420 f"❌ Failed to create repository at '{sanitize_display(str(target))}': {exc}",
421 file=sys.stderr,
422 )
423 shutil.rmtree(target, ignore_errors=True)
424 raise SystemExit(ExitCode.INTERNAL_ERROR)
425
426 # ── MWP single-POST fetch/stream ─────────────────────────────────────────
427 # Clone always starts with an empty have list — no local history yet.
428 # Objects are written to the local store as each O frame arrives,
429 # enabling streaming disk writes that overlap with continued network reads.
430 want = list(info["branch_heads"].values())
431 objects_written: int = 0
432 wire_bytes: int = 0
433
434 def _on_object(obj: ObjectPayload) -> None:
435 nonlocal objects_written, wire_bytes
436 raw = obj["content"]
437 if raw:
438 wire_bytes += len(raw)
439 try:
440 if write_object(target, obj["object_id"], raw):
441 objects_written += 1
442 except ValueError as exc:
443 logger.warning("clone: skipping corrupted object %s: %s", short_id(obj["object_id"]), exc)
444
445 t0 = time.perf_counter()
446 try:
447 stream_result = transport.fetch_stream(
448 url, signing,
449 want=want,
450 have=[],
451 on_object=_on_object,
452 )
453 except TransportError as exc:
454 if json_out:
455 print(json.dumps(_CloneErrorJson(
456 **make_envelope(elapsed, exit_code=ExitCode.INTERNAL_ERROR),
457 error="fetch_failed",
458 url=url,
459 message=str(exc),
460 )))
461 print(f"❌ Fetch failed: {exc}", file=sys.stderr)
462 shutil.rmtree(target, ignore_errors=True)
463 raise SystemExit(ExitCode.INTERNAL_ERROR)
464 t_stream = time.perf_counter() - t0
465
466 apply_result: ApplyResult = apply_mpack(target, {
467 "commits": stream_result["commits"],
468 "snapshots": stream_result["snapshots"],
469 })
470 wire_kib = wire_bytes / 1024
471 print(
472 f"[stream] fetch/stream: {t_stream:.2f}s "
473 f"objects: {stream_result['objects_received']} "
474 f"commits: {apply_result['commits_written']} "
475 f"({wire_kib:.1f} KiB wire)",
476 file=sys.stderr,
477 )
478
479 # Write branch head refs for every remote branch atomically and record
480 # the remote tracking pointer so future fetches can detect staleness.
481 for b, cid in info["branch_heads"].items():
482 ref_file = _ref_path(target, b)
483 ref_file.parent.mkdir(parents=True, exist_ok=True)
484 write_text_atomic(ref_file, cid)
485 set_remote_head("origin", b, cid, target)
486
487 # Configure origin remote and upstream tracking.
488 set_remote("origin", url, target)
489 set_upstream(default_branch, "origin", target)
490
491 # Restore working tree unless the caller opted out.
492 if not no_checkout:
493 _restore_working_tree(target, default_commit_id)
494
495 commits_received = apply_result["commits_written"]
496
497 if json_out:
498 print(json.dumps(_CloneJson(
499 **make_envelope(elapsed),
500 status="cloned",
501 url=url,
502 directory=str(target),
503 branch=default_branch,
504 commits_received=commits_received,
505 objects_written=objects_written,
506 head=default_commit_id,
507 domain=domain,
508 dry_run=False,
509 )))
510 else:
511 print(
512 f"✅ Cloned into '{sanitize_display(target_name)}' — "
513 f"{commits_received} commit(s), {objects_written} object(s), "
514 f"domain={sanitize_display(domain)}, "
515 f"branch={sanitize_display(default_branch)} ({short_id(default_commit_id)})",
516 file=sys.stderr,
517 )
518
519 logger.info(
520 "✅ clone: %s → %s commits=%d objects=%d",
521 url, target, commits_received, objects_written,
522 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago