clone.py python
467 lines 16.2 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 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 datetime
55 import json
56 import logging
57 import pathlib
58 import shutil
59 import sys
60 import uuid
61 from typing import TYPE_CHECKING, TypedDict
62
63 from muse._version import __version__ as _SCHEMA_VERSION
64 from muse.cli.config import get_signing_identity, set_remote, set_remote_head, set_upstream
65 from muse.core.errors import ExitCode
66 from muse.core.object_store import has_object, write_object
67 from muse.core.pack import apply_pack
68 from muse.core.store import (
69 read_commit,
70 read_snapshot,
71 write_head_branch,
72 write_text_atomic,
73 )
74 from muse.core.transport import TransportError, make_transport
75 from muse.core.validation import sanitize_display
76 from muse.core.workdir import apply_manifest
77
78
79 type _RepoMeta = dict[str, str]
80 if TYPE_CHECKING:
81 from muse.core.pack import ApplyResult
82
83 logger = logging.getLogger(__name__)
84
85 # Canonical set of subdirectories — must match muse init's _INIT_SUBDIRS.
86 _CLONE_SUBDIRS: tuple[str, ...] = (
87 "refs",
88 "refs/heads",
89 "objects",
90 "commits",
91 "snapshots",
92 "tags",
93 )
94
95 _DEFAULT_CONFIG = """\
96 [user]
97 name = ""
98 email = ""
99
100 [remotes]
101
102 [domain]
103 # Domain-specific configuration keys depend on the active domain.
104 """
105
106
107 class _CloneJson(TypedDict):
108 """Stable JSON schema emitted by ``muse clone --json``."""
109
110 status: str # "cloned" | "dry_run" | "already_exists"
111 url: str
112 directory: str # resolved local path
113 branch: str # branch checked out
114 commits_received: int
115 objects_written: int
116 head: str | None # HEAD commit ID after clone, null on dry-run
117 domain: str
118 dry_run: bool
119
120
121 def _infer_dir_name(url: str) -> str:
122 """Derive a safe local directory name from the last non-empty segment of *url*.
123
124 Strips query strings, fragments, and path-traversal components so that a
125 crafted URL like ``http://evil.example.com/../../../../etc`` cannot escape
126 the current working directory.
127 """
128 # Drop fragment and query before splitting on path separators.
129 stripped = url.split("#")[0].split("?")[0].rstrip("/")
130 last = stripped.rsplit("/", 1)[-1]
131 # pathlib.Path.name always strips leading dots and directory separators,
132 # eliminating traversal attempts like ".." or "../../secret".
133 safe = pathlib.PurePosixPath(last).name
134 return safe if safe and safe not in (".", "..") else "muse-repo"
135
136
137 def _init_muse_dir(
138 target: pathlib.Path,
139 repo_id: str,
140 domain: str,
141 default_branch: str,
142 ) -> None:
143 """Create the ``.muse/`` directory tree inside *target*.
144
145 Uses the same subdirectory set as ``muse init`` so that every command that
146 relies on the standard layout (tags, objects, etc.) works out of the box.
147 """
148 muse_dir = target / ".muse"
149 for subdir in _CLONE_SUBDIRS:
150 (muse_dir / subdir).mkdir(parents=True, exist_ok=True)
151
152 repo_meta: _RepoMeta = {
153 "repo_id": repo_id,
154 "schema_version": _SCHEMA_VERSION,
155 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
156 "domain": domain,
157 }
158 write_text_atomic(muse_dir / "repo.json", json.dumps(repo_meta, indent=2) + "\n")
159 write_head_branch(muse_dir.parent, default_branch)
160 write_text_atomic(muse_dir / "refs" / "heads" / default_branch, "")
161 write_text_atomic(muse_dir / "config.toml", _DEFAULT_CONFIG)
162
163
164 def _restore_working_tree(root: pathlib.Path, commit_id: str) -> None:
165 """Restore the working tree to the snapshot referenced by *commit_id*.
166
167 Logs a warning to stderr (rather than silently returning) if the commit or
168 snapshot cannot be read — this surfaces bugs where apply_pack did not write
169 the expected objects.
170 """
171 commit = read_commit(root, commit_id)
172 if commit is None:
173 logger.warning(
174 "⚠️ clone: commit %s not found after apply_pack — working tree not restored",
175 commit_id[:8],
176 )
177 return
178 snap = read_snapshot(root, commit.snapshot_id)
179 if snap is None:
180 logger.warning(
181 "⚠️ clone: snapshot %s not found after apply_pack — working tree not restored",
182 commit.snapshot_id[:8],
183 )
184 return
185 apply_manifest(root, snap.manifest)
186
187
188 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
189 """Register the ``muse clone`` subcommand and all its flags."""
190 parser = subparsers.add_parser(
191 "clone",
192 help="Create a local copy of a remote Muse repository.",
193 description=__doc__,
194 formatter_class=argparse.RawDescriptionHelpFormatter,
195 )
196 parser.add_argument(
197 "url",
198 help="URL of the remote Muse repository to clone.",
199 )
200 parser.add_argument(
201 "directory",
202 nargs="?",
203 default=None,
204 help=(
205 "Local directory to clone into. "
206 "Defaults to the last path segment of the URL."
207 ),
208 )
209 parser.add_argument(
210 "--branch", "-b",
211 default=None,
212 help="Branch to check out after cloning (default: remote default branch).",
213 )
214 parser.add_argument(
215 "--dry-run", "-n",
216 action="store_true",
217 default=False,
218 dest="dry_run",
219 help=(
220 "Contact the remote and show what would be cloned without writing "
221 "any files or creating any directories."
222 ),
223 )
224 parser.add_argument(
225 "--no-checkout",
226 action="store_true",
227 default=False,
228 dest="no_checkout",
229 help="Skip restoring the working tree after cloning.",
230 )
231 fmt_group = parser.add_mutually_exclusive_group()
232 fmt_group.add_argument(
233 "--format",
234 choices=["text", "json"],
235 default="text",
236 dest="format",
237 help="Output format: 'text' (default) or 'json'.",
238 )
239 fmt_group.add_argument(
240 "--json",
241 action="store_const",
242 const="json",
243 dest="format",
244 help="Shorthand for --format json.",
245 )
246 parser.set_defaults(func=run)
247
248
249 def run(args: argparse.Namespace) -> None:
250 """Clone a remote Muse repository into a new local directory.
251
252 Downloads the full commit history, snapshots, objects, and branch heads.
253 Configures ``origin`` and upstream tracking. Checks out the default (or
254 requested) branch unless ``--no-checkout`` is given.
255
256 All progress and diagnostic messages go to **stderr**. ``--json``
257 (or ``--format json``) emits a single :class:`_CloneJson` object on stdout.
258
259 On any error after the target directory has been partially created,
260 the directory is removed to leave the filesystem clean.
261 """
262 url: str = args.url
263 directory: str | None = args.directory
264 branch: str | None = args.branch
265 dry_run: bool = args.dry_run
266 no_checkout: bool = args.no_checkout
267 fmt: str = args.format
268
269 # clone does not need to be inside a Muse repo — it creates a new one.
270 # Resolve the target name and path before any network I/O.
271 target_name = directory or _infer_dir_name(url)
272 target = pathlib.Path.cwd() / target_name
273
274 if dry_run:
275 print("(dry run — no files will be created)", file=sys.stderr)
276
277 if (target / ".muse").exists():
278 msg = f"❌ '{sanitize_display(str(target))}' is already a Muse repository."
279 print(msg, file=sys.stderr)
280 if fmt == "json":
281 out: _CloneJson = {
282 "status": "already_exists",
283 "url": url,
284 "directory": str(target),
285 "branch": branch or "",
286 "commits_received": 0,
287 "objects_written": 0,
288 "head": None,
289 "domain": "",
290 "dry_run": dry_run,
291 }
292 print(json.dumps(out))
293 raise SystemExit(ExitCode.USER_ERROR)
294
295 signing = get_signing_identity(remote_url=url)
296
297 transport = make_transport(url)
298
299 print(
300 f"Cloning from {sanitize_display(url)} …",
301 file=sys.stderr,
302 )
303 try:
304 info = transport.fetch_remote_info(url, signing=signing)
305 except TransportError as exc:
306 print(f"❌ Cannot reach remote: {exc}", file=sys.stderr)
307 raise SystemExit(ExitCode.INTERNAL_ERROR)
308
309 # Use "code" as the domain fallback — "midi" was the first plugin but is
310 # not the canonical default domain for new repositories.
311 remote_repo_id = info["repo_id"] or str(uuid.uuid4())
312 domain = info["domain"] or "code"
313 default_branch = branch or info["default_branch"] or "main"
314
315 if not info["branch_heads"]:
316 print(
317 "❌ Remote repository has no branches (empty repository).",
318 file=sys.stderr,
319 )
320 raise SystemExit(ExitCode.USER_ERROR)
321
322 default_commit_id = info["branch_heads"].get(default_branch)
323 if default_commit_id is None:
324 # Fall back to the first available branch rather than failing hard —
325 # a user who requests a non-existent branch gets a clear warning.
326 first_branch, default_commit_id = next(iter(info["branch_heads"].items()))
327 print(
328 f" ⚠️ Branch '{sanitize_display(default_branch)}' not found on remote; "
329 f"checking out '{sanitize_display(first_branch)}' instead.",
330 file=sys.stderr,
331 )
332 default_branch = first_branch
333
334 available = sorted(info["branch_heads"])
335 logger.debug(
336 "Remote has %d branch(es): %s",
337 len(available),
338 ", ".join(sanitize_display(b) for b in available),
339 )
340
341 # ── dry-run exits here — no filesystem changes after this point ──────────
342 if dry_run:
343 want_count = len(info["branch_heads"])
344 result: _CloneJson = {
345 "status": "dry_run",
346 "url": url,
347 "directory": str(target),
348 "branch": default_branch,
349 "commits_received": 0,
350 "objects_written": 0,
351 "head": default_commit_id,
352 "domain": domain,
353 "dry_run": True,
354 }
355 if fmt == "json":
356 print(json.dumps(result))
357 else:
358 print(
359 f"Would clone {sanitize_display(url)} → {sanitize_display(str(target))}",
360 file=sys.stderr,
361 )
362 print(
363 f" branch={sanitize_display(default_branch)}, "
364 f"domain={sanitize_display(domain)}, "
365 f"{want_count} branch head(s) to fetch",
366 file=sys.stderr,
367 )
368 return
369
370 # ── real clone ────────────────────────────────────────────────────────────
371 target.mkdir(parents=True, exist_ok=True)
372 try:
373 _init_muse_dir(target, remote_repo_id, domain, default_branch)
374 except OSError as exc:
375 print(
376 f"❌ Failed to create repository at '{sanitize_display(str(target))}': {exc}",
377 file=sys.stderr,
378 )
379 shutil.rmtree(target, ignore_errors=True)
380 raise SystemExit(ExitCode.INTERNAL_ERROR)
381
382 # Fetch full pack — clone always starts with an empty have list because
383 # there are no local objects yet.
384 want = list(info["branch_heads"].values())
385 try:
386 bundle = transport.fetch_pack(url, signing=signing, want=want, have=[])
387 except TransportError as exc:
388 print(f"❌ Fetch failed: {exc}", file=sys.stderr)
389 shutil.rmtree(target, ignore_errors=True)
390 raise SystemExit(ExitCode.INTERNAL_ERROR)
391
392 apply_result: ApplyResult = apply_pack(target, bundle)
393 objects_written: int = apply_result["objects_written"]
394
395 # ── Phase 2: fetch object bytes for the default checkout target ──────────
396 # fetch_pack returns only VCS metadata (commits + snapshots). We fetch
397 # only the objects needed for the default checkout to keep clone fast even
398 # for repos with large historical object sets.
399 default_commit = read_commit(target, default_commit_id)
400 if default_commit and default_commit.snapshot_id:
401 default_snap = read_snapshot(target, default_commit.snapshot_id)
402 if default_snap:
403 needed_oids = list(default_snap.manifest.values())
404 missing_oids = [oid for oid in needed_oids if not has_object(target, oid)]
405 if missing_oids:
406 logger.debug(
407 "clone: requesting %d missing object(s) via /fetch/objects",
408 len(missing_oids),
409 )
410 try:
411 fetched_objs = transport.fetch_objects(url, signing=signing, object_ids=missing_oids)
412 except TransportError as exc:
413 print(f"❌ Fetch objects failed: {exc}", file=sys.stderr)
414 shutil.rmtree(target, ignore_errors=True)
415 raise SystemExit(ExitCode.INTERNAL_ERROR)
416 for obj in fetched_objs:
417 oid = obj.get("object_id", "")
418 raw = obj.get("content", b"")
419 if oid and isinstance(raw, bytes):
420 if write_object(target, oid, raw):
421 objects_written += 1
422
423 # Write branch head refs for every remote branch atomically and record
424 # the remote tracking pointer so future fetches can detect staleness.
425 for b, cid in info["branch_heads"].items():
426 ref_file = target / ".muse" / "refs" / "heads" / b
427 ref_file.parent.mkdir(parents=True, exist_ok=True)
428 write_text_atomic(ref_file, cid)
429 set_remote_head("origin", b, cid, target)
430
431 # Configure origin remote and upstream tracking.
432 set_remote("origin", url, target)
433 set_upstream(default_branch, "origin", target)
434
435 # Restore working tree unless the caller opted out.
436 if not no_checkout:
437 _restore_working_tree(target, default_commit_id)
438
439 commits_received = apply_result["commits_written"]
440
441 clone_result: _CloneJson = {
442 "status": "cloned",
443 "url": url,
444 "directory": str(target),
445 "branch": default_branch,
446 "commits_received": commits_received,
447 "objects_written": objects_written,
448 "head": default_commit_id,
449 "domain": domain,
450 "dry_run": False,
451 }
452
453 if fmt == "json":
454 print(json.dumps(clone_result))
455 else:
456 print(
457 f"✅ Cloned into '{sanitize_display(target_name)}' — "
458 f"{commits_received} commit(s), {objects_written} object(s), "
459 f"domain={sanitize_display(domain)}, "
460 f"branch={sanitize_display(default_branch)} ({default_commit_id[:8]})",
461 file=sys.stderr,
462 )
463
464 logger.info(
465 "✅ clone: %s → %s commits=%d objects=%d",
466 url, target, commits_received, objects_written,
467 )
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago