cgcardona / muse public
clone.py python
395 lines 14.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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))