clone.py
python
| 1 | """muse clone <url> [directory] — clone a Muse Hub repository locally. |
| 2 | |
| 3 | Clone algorithm |
| 4 | --------------- |
| 5 | 1. Parse *url* to derive the default target directory name (last URL path |
| 6 | component, stripped of trailing slashes). |
| 7 | 2. Resolve the effective target directory (explicit *directory* arg or the |
| 8 | derived default). Abort if it already exists. |
| 9 | 3. Create the target directory and initialise ``.muse/`` structure (mirroring |
| 10 | ``muse init`` without re-invoking it): |
| 11 | <target>/.muse/repo.json — stub; repo_id written after Hub reply |
| 12 | <target>/.muse/HEAD — refs/heads/<effective_branch> |
| 13 | <target>/.muse/refs/heads/ — ref files populated after clone |
| 14 | <target>/.muse/config.toml — origin remote set to *url* |
| 15 | 4. POST to ``<url>/clone`` with branch, depth, single_track parameters. |
| 16 | 5. Write ``repo_id`` returned by Hub into ``.muse/repo.json``. |
| 17 | 6. Store returned commits and object descriptors in local Postgres. |
| 18 | 7. Update ``.muse/refs/heads/<branch>`` to the remote HEAD commit ID. |
| 19 | 8. Write remote-tracking pointer to ``.muse/remotes/origin/<branch>``. |
| 20 | 9. Unless ``--no-checkout``, create ``muse-work/`` in the target directory. |
| 21 | |
| 22 | Exit codes: |
| 23 | 0 — success |
| 24 | 1 — user error (target directory exists, bad args) |
| 25 | 3 — network / server error |
| 26 | """ |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | import asyncio |
| 30 | import datetime |
| 31 | import json |
| 32 | import logging |
| 33 | import pathlib |
| 34 | import shutil |
| 35 | import uuid |
| 36 | |
| 37 | import httpx |
| 38 | import typer |
| 39 | |
| 40 | from maestro.muse_cli.config import set_remote, set_remote_head |
| 41 | from maestro.muse_cli.db import ( |
| 42 | open_session, |
| 43 | store_pulled_commit, |
| 44 | store_pulled_object, |
| 45 | ) |
| 46 | from maestro.muse_cli.errors import ExitCode |
| 47 | from maestro.muse_cli.hub_client import ( |
| 48 | CloneRequest, |
| 49 | CloneResponse, |
| 50 | MuseHubClient, |
| 51 | ) |
| 52 | |
| 53 | logger = logging.getLogger(__name__) |
| 54 | |
| 55 | app = typer.Typer() |
| 56 | |
| 57 | _SCHEMA_VERSION = "1" |
| 58 | |
| 59 | _DEFAULT_CONFIG_TOML = """\ |
| 60 | [user] |
| 61 | name = "" |
| 62 | email = "" |
| 63 | |
| 64 | [auth] |
| 65 | token = "" |
| 66 | |
| 67 | [remotes] |
| 68 | """ |
| 69 | |
| 70 | |
| 71 | # --------------------------------------------------------------------------- |
| 72 | # Internal helpers |
| 73 | # --------------------------------------------------------------------------- |
| 74 | |
| 75 | |
| 76 | def _derive_directory_name(url: str) -> str: |
| 77 | """Derive a directory name from a Hub URL. |
| 78 | |
| 79 | Strips trailing slashes and returns the last path component. Falls back |
| 80 | to ``"muse-clone"`` when the URL has no meaningful path segment. |
| 81 | |
| 82 | Args: |
| 83 | url: Muse Hub repo URL (e.g. ``"https://hub.stori.app/repos/my-project"``). |
| 84 | |
| 85 | Returns: |
| 86 | A suitable local directory name string. |
| 87 | """ |
| 88 | stripped = url.rstrip("/") |
| 89 | last = stripped.rsplit("/", 1)[-1] |
| 90 | return last if last and last not in ("repos", "musehub") else "muse-clone" |
| 91 | |
| 92 | |
| 93 | def _init_muse_dir( |
| 94 | target: pathlib.Path, |
| 95 | branch: str, |
| 96 | origin_url: str, |
| 97 | ) -> None: |
| 98 | """Create the ``.muse/`` skeleton inside *target*. |
| 99 | |
| 100 | Writes a stub ``repo.json`` (repo_id filled in after Hub reply), |
| 101 | ``HEAD`` pointing at *branch*, and ``config.toml`` with the origin remote. |
| 102 | Does NOT write the branch ref file — that is written after the Hub returns |
| 103 | the remote HEAD commit ID. |
| 104 | |
| 105 | Args: |
| 106 | target: Repository root directory (must exist and be empty). |
| 107 | branch: Default branch name (written to HEAD). |
| 108 | origin_url: Remote URL written as ``[remotes.origin]`` in config.toml. |
| 109 | """ |
| 110 | muse_dir = target / ".muse" |
| 111 | (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True) |
| 112 | (muse_dir / "remotes").mkdir(parents=True, exist_ok=True) |
| 113 | |
| 114 | # Stub repo.json — repo_id is overwritten once Hub responds. |
| 115 | stub_repo: dict[str, str] = { |
| 116 | "repo_id": str(uuid.uuid4()), |
| 117 | "schema_version": _SCHEMA_VERSION, |
| 118 | "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 119 | } |
| 120 | (muse_dir / "repo.json").write_text( |
| 121 | json.dumps(stub_repo, indent=2) + "\n", encoding="utf-8" |
| 122 | ) |
| 123 | |
| 124 | # HEAD pointer |
| 125 | (muse_dir / "HEAD").write_text(f"refs/heads/{branch}\n", encoding="utf-8") |
| 126 | |
| 127 | # Empty branch ref (no commits yet). |
| 128 | # Branch names may contain slashes (e.g. "feature/guitar") so we must |
| 129 | # create the intermediate directory before writing the ref file. |
| 130 | ref_file = muse_dir / "refs" / "heads" / branch |
| 131 | ref_file.parent.mkdir(parents=True, exist_ok=True) |
| 132 | ref_file.write_text("", encoding="utf-8") |
| 133 | |
| 134 | # config.toml with origin remote |
| 135 | config_path = muse_dir / "config.toml" |
| 136 | config_path.write_text(_DEFAULT_CONFIG_TOML, encoding="utf-8") |
| 137 | # Use set_remote to write the [remotes.origin] section properly. |
| 138 | set_remote("origin", origin_url, target) |
| 139 | |
| 140 | |
| 141 | # --------------------------------------------------------------------------- |
| 142 | # Async clone core |
| 143 | # --------------------------------------------------------------------------- |
| 144 | |
| 145 | |
| 146 | async def _clone_async( |
| 147 | *, |
| 148 | url: str, |
| 149 | directory: str | None, |
| 150 | depth: int | None, |
| 151 | branch: str | None, |
| 152 | single_track: str | None, |
| 153 | no_checkout: bool, |
| 154 | ) -> None: |
| 155 | """Execute the clone pipeline. |
| 156 | |
| 157 | Raises :class:`typer.Exit` with the appropriate exit code on all error |
| 158 | paths so the Typer callback can remain thin. |
| 159 | |
| 160 | Args: |
| 161 | url: Muse Hub repo URL to clone from. |
| 162 | directory: Local target directory path (derived from URL if None). |
| 163 | depth: Shallow-clone depth (number of commits to fetch). |
| 164 | branch: Branch to clone and check out (Hub default if None). |
| 165 | single_track: Instrument track filter — only files whose first path |
| 166 | component matches this string are downloaded. |
| 167 | no_checkout: When True, skip populating ``muse-work/``. |
| 168 | """ |
| 169 | # ── Resolve target directory ────────────────────────────────────────── |
| 170 | target_name = directory or _derive_directory_name(url) |
| 171 | target = pathlib.Path(target_name).resolve() |
| 172 | |
| 173 | if target.exists(): |
| 174 | typer.echo( |
| 175 | f"❌ Destination '{target}' already exists.\n" |
| 176 | " Choose a different directory or remove it first." |
| 177 | ) |
| 178 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 179 | |
| 180 | # ── Determine effective branch (placeholder until Hub responds) ─────── |
| 181 | effective_branch = branch or "main" |
| 182 | |
| 183 | typer.echo(f"Cloning into '{target.name}' …") |
| 184 | |
| 185 | # ── Create target directory and initialise .muse/ ───────────────────── |
| 186 | target_created = False |
| 187 | try: |
| 188 | target.mkdir(parents=True, exist_ok=False) |
| 189 | target_created = True |
| 190 | _init_muse_dir(target, effective_branch, url) |
| 191 | except PermissionError as exc: |
| 192 | typer.echo(f"❌ Permission denied creating '{target}': {exc}") |
| 193 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 194 | except OSError as exc: |
| 195 | typer.echo(f"❌ Failed to create repository at '{target}': {exc}") |
| 196 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 197 | |
| 198 | # ── HTTP clone request ──────────────────────────────────────────────── |
| 199 | clone_request = CloneRequest( |
| 200 | branch=branch, |
| 201 | depth=depth, |
| 202 | single_track=single_track, |
| 203 | ) |
| 204 | |
| 205 | try: |
| 206 | async with MuseHubClient(base_url=url, repo_root=target) as hub: |
| 207 | response = await hub.post("/clone", json=clone_request) |
| 208 | |
| 209 | if response.status_code != 200: |
| 210 | typer.echo( |
| 211 | f"❌ Hub rejected clone (HTTP {response.status_code}): {response.text}" |
| 212 | ) |
| 213 | logger.error( |
| 214 | "❌ muse clone failed: HTTP %d — %s", |
| 215 | response.status_code, |
| 216 | response.text, |
| 217 | ) |
| 218 | if target_created: |
| 219 | shutil.rmtree(target, ignore_errors=True) |
| 220 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 221 | |
| 222 | except typer.Exit: |
| 223 | raise |
| 224 | except httpx.TimeoutException: |
| 225 | typer.echo(f"❌ Clone timed out connecting to {url}") |
| 226 | if target_created: |
| 227 | shutil.rmtree(target, ignore_errors=True) |
| 228 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 229 | except httpx.HTTPError as exc: |
| 230 | typer.echo(f"❌ Network error during clone: {exc}") |
| 231 | logger.error("❌ muse clone network error: %s", exc, exc_info=True) |
| 232 | if target_created: |
| 233 | shutil.rmtree(target, ignore_errors=True) |
| 234 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 235 | |
| 236 | # ── Parse response ──────────────────────────────────────────────────── |
| 237 | raw_body: object = response.json() |
| 238 | if not isinstance(raw_body, dict): |
| 239 | typer.echo("❌ Hub returned unexpected clone response shape.") |
| 240 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 241 | |
| 242 | raw_repo_id = raw_body.get("repo_id") |
| 243 | raw_default_branch = raw_body.get("default_branch", effective_branch) |
| 244 | raw_remote_head = raw_body.get("remote_head") |
| 245 | |
| 246 | clone_response = CloneResponse( |
| 247 | repo_id=str(raw_repo_id) if isinstance(raw_repo_id, str) else str(uuid.uuid4()), |
| 248 | default_branch=str(raw_default_branch) if isinstance(raw_default_branch, str) else effective_branch, |
| 249 | remote_head=str(raw_remote_head) if isinstance(raw_remote_head, str) else None, |
| 250 | commits=list(raw_body.get("commits", [])), |
| 251 | objects=list(raw_body.get("objects", [])), |
| 252 | ) |
| 253 | |
| 254 | repo_id = clone_response["repo_id"] |
| 255 | resolved_branch = clone_response["default_branch"] |
| 256 | remote_head = clone_response["remote_head"] |
| 257 | |
| 258 | # ── Write canonical repo_id returned by Hub ─────────────────────────── |
| 259 | muse_dir = target / ".muse" |
| 260 | repo_json: dict[str, str] = { |
| 261 | "repo_id": repo_id, |
| 262 | "schema_version": _SCHEMA_VERSION, |
| 263 | "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 264 | } |
| 265 | (muse_dir / "repo.json").write_text( |
| 266 | json.dumps(repo_json, indent=2) + "\n", encoding="utf-8" |
| 267 | ) |
| 268 | |
| 269 | # Update HEAD to the resolved branch (may differ from our placeholder). |
| 270 | (muse_dir / "HEAD").write_text( |
| 271 | f"refs/heads/{resolved_branch}\n", encoding="utf-8" |
| 272 | ) |
| 273 | |
| 274 | # ── Store commits and objects in local DB ───────────────────────────── |
| 275 | new_commits_count = 0 |
| 276 | new_objects_count = 0 |
| 277 | |
| 278 | async with open_session() as session: |
| 279 | for commit_data in clone_response["commits"]: |
| 280 | if isinstance(commit_data, dict): |
| 281 | commit_data_with_repo = dict(commit_data) |
| 282 | commit_data_with_repo.setdefault("repo_id", repo_id) |
| 283 | inserted = await store_pulled_commit(session, commit_data_with_repo) |
| 284 | if inserted: |
| 285 | new_commits_count += 1 |
| 286 | |
| 287 | for obj_data in clone_response["objects"]: |
| 288 | if isinstance(obj_data, dict): |
| 289 | inserted = await store_pulled_object(session, dict(obj_data)) |
| 290 | if inserted: |
| 291 | new_objects_count += 1 |
| 292 | |
| 293 | # ── Update local branch ref and remote-tracking pointer ─────────────── |
| 294 | if remote_head: |
| 295 | ref_path = muse_dir / "refs" / "heads" / resolved_branch |
| 296 | ref_path.parent.mkdir(parents=True, exist_ok=True) |
| 297 | ref_path.write_text(remote_head, encoding="utf-8") |
| 298 | set_remote_head("origin", resolved_branch, remote_head, target) |
| 299 | |
| 300 | # ── Populate muse-work/ unless --no-checkout ────────────────────────── |
| 301 | if not no_checkout: |
| 302 | work_dir = target / "muse-work" |
| 303 | work_dir.mkdir(exist_ok=True) |
| 304 | logger.debug("✅ Created muse-work/ in %s", target) |
| 305 | |
| 306 | # ── Summary ─────────────────────────────────────────────────────────── |
| 307 | depth_note = f" (depth {depth})" if depth is not None else "" |
| 308 | track_note = f", track={single_track!r}" if single_track else "" |
| 309 | checkout_note = " (no checkout)" if no_checkout else "" |
| 310 | typer.echo( |
| 311 | f"✅ Cloned{depth_note}{track_note}{checkout_note}: " |
| 312 | f"{new_commits_count} commit(s), {new_objects_count} object(s) " |
| 313 | f"→ '{target.name}'" |
| 314 | ) |
| 315 | logger.info( |
| 316 | "✅ muse clone %s → %s: +%d commits, +%d objects, branch=%s", |
| 317 | url, |
| 318 | target, |
| 319 | new_commits_count, |
| 320 | new_objects_count, |
| 321 | resolved_branch, |
| 322 | ) |
| 323 | |
| 324 | |
| 325 | # --------------------------------------------------------------------------- |
| 326 | # Typer command |
| 327 | # --------------------------------------------------------------------------- |
| 328 | |
| 329 | |
| 330 | @app.callback(invoke_without_command=True) |
| 331 | def clone( |
| 332 | ctx: typer.Context, |
| 333 | url: str = typer.Argument(..., help="Muse Hub repository URL to clone from."), |
| 334 | directory: str | None = typer.Argument( |
| 335 | None, |
| 336 | help="Local directory to clone into. Defaults to the repo name from the URL.", |
| 337 | ), |
| 338 | depth: int | None = typer.Option( |
| 339 | None, |
| 340 | "--depth", |
| 341 | help="Shallow clone: fetch only the last N commits.", |
| 342 | min=1, |
| 343 | ), |
| 344 | branch: str | None = typer.Option( |
| 345 | None, |
| 346 | "--branch", |
| 347 | "-b", |
| 348 | help="Clone and check out a specific branch instead of the Hub default.", |
| 349 | ), |
| 350 | single_track: str | None = typer.Option( |
| 351 | None, |
| 352 | "--single-track", |
| 353 | help=( |
| 354 | "Clone only files matching a specific instrument track " |
| 355 | "(e.g. 'drums', 'keys'). Filters by first path component." |
| 356 | ), |
| 357 | ), |
| 358 | no_checkout: bool = typer.Option( |
| 359 | False, |
| 360 | "--no-checkout", |
| 361 | help="Set up .muse/ and fetch objects but leave muse-work/ empty.", |
| 362 | ), |
| 363 | ) -> None: |
| 364 | """Clone a Muse Hub repository into a new local directory. |
| 365 | |
| 366 | Creates a new directory, initialises ``.muse/``, fetches all commits and |
| 367 | objects from the Hub, and populates ``muse-work/`` with the HEAD snapshot. |
| 368 | Writes "origin" to ``.muse/config.toml`` pointing at *url*. |
| 369 | |
| 370 | Examples:: |
| 371 | |
| 372 | muse clone https://hub.stori.app/repos/my-project |
| 373 | muse clone https://hub.stori.app/repos/my-project ./collab |
| 374 | muse clone https://hub.stori.app/repos/my-project --depth 1 |
| 375 | muse clone https://hub.stori.app/repos/my-project --branch feature/guitar |
| 376 | muse clone https://hub.stori.app/repos/my-project --single-track keys |
| 377 | muse clone https://hub.stori.app/repos/my-project --no-checkout |
| 378 | """ |
| 379 | try: |
| 380 | asyncio.run( |
| 381 | _clone_async( |
| 382 | url=url, |
| 383 | directory=directory, |
| 384 | depth=depth, |
| 385 | branch=branch, |
| 386 | single_track=single_track, |
| 387 | no_checkout=no_checkout, |
| 388 | ) |
| 389 | ) |
| 390 | except typer.Exit: |
| 391 | raise |
| 392 | except Exception as exc: |
| 393 | typer.echo(f"❌ muse clone failed: {exc}") |
| 394 | logger.error("❌ muse clone unexpected error: %s", exc, exc_info=True) |
| 395 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |