gabriel / muse public
clone.py python
192 lines 6.6 KB
7855ccd0 feat: harden, test, and document all quality-dial changes Gabriel Cardona <gabriel@tellurstori.com> 2d 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 (the cloned directory root).
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
16 The target directory must not already contain a ``.muse/`` repository.
17 """
18
19 from __future__ import annotations
20
21 import datetime
22 import json
23 import logging
24 import pathlib
25 import shutil
26 import uuid
27
28 import typer
29
30 from muse._version import __version__ as _SCHEMA_VERSION
31 from muse.cli.config import set_remote, set_remote_head, set_upstream
32 from muse.core.errors import ExitCode
33 from muse.core.pack import apply_pack
34 from muse.core.store import get_all_commits, read_commit, read_snapshot, write_head_branch
35 from muse.core.transport import TransportError, make_transport
36 from muse.core.workdir import apply_manifest
37
38 logger = logging.getLogger(__name__)
39
40 app = typer.Typer()
41
42 _DEFAULT_CONFIG = """\
43 [user]
44 name = ""
45 email = ""
46
47 [auth]
48 token = ""
49
50 [remotes]
51
52 [domain]
53 # Domain-specific configuration keys depend on the active domain.
54 """
55
56
57 def _infer_dir_name(url: str) -> str:
58 """Derive a local directory name from the last non-empty segment of *url*."""
59 stripped = url.rstrip("/")
60 last = stripped.rsplit("/", 1)[-1]
61 return last if last else "muse-repo"
62
63
64 def _init_muse_dir(
65 target: pathlib.Path,
66 repo_id: str,
67 domain: str,
68 default_branch: str,
69 ) -> None:
70 """Create the ``.muse/`` directory tree inside *target*.
71
72 Mirrors the layout created by ``muse init`` but uses the remote's
73 ``repo_id`` and ``domain`` so local and remote identity stay in sync.
74 """
75 muse_dir = target / ".muse"
76 (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
77 for subdir in ("objects", "commits", "snapshots"):
78 (muse_dir / subdir).mkdir(exist_ok=True)
79
80 repo_meta: dict[str, str] = {
81 "repo_id": repo_id,
82 "schema_version": _SCHEMA_VERSION,
83 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
84 "domain": domain,
85 }
86 (muse_dir / "repo.json").write_text(json.dumps(repo_meta, indent=2) + "\n")
87 write_head_branch(muse_dir.parent, default_branch)
88 (muse_dir / "refs" / "heads" / default_branch).write_text("")
89 (muse_dir / "config.toml").write_text(_DEFAULT_CONFIG)
90
91 def _restore_working_tree(root: pathlib.Path, commit_id: str) -> None:
92 """Restore the working tree to the snapshot referenced by *commit_id*."""
93 commit = read_commit(root, commit_id)
94 if commit is None:
95 return
96 snap = read_snapshot(root, commit.snapshot_id)
97 if snap is None:
98 return
99 apply_manifest(root, snap.manifest)
100
101
102 @app.callback(invoke_without_command=True)
103 def clone(
104 ctx: typer.Context,
105 url: str = typer.Argument(..., help="URL of the remote Muse repository to clone."),
106 directory: str | None = typer.Argument(
107 None,
108 help="Local directory to clone into. Defaults to the last segment of the URL.",
109 ),
110 branch: str | None = typer.Option(
111 None, "--branch", "-b", help="Branch to check out after cloning (default: remote default branch)."
112 ),
113 ) -> None:
114 """Create a local copy of a remote Muse repository.
115
116 Downloads the full commit history, snapshots, and objects and checks out
117 the default branch. The cloned repo has ``origin`` set to *url*.
118 """
119 # clone does not need to be inside a Muse repo — it creates a new one.
120 target_name = directory or _infer_dir_name(url)
121 target = pathlib.Path.cwd() / target_name
122
123 if (target / ".muse").exists():
124 typer.echo(f"❌ '{target}' is already a Muse repository.")
125 raise typer.Exit(code=ExitCode.USER_ERROR)
126
127 transport = make_transport(url)
128
129 # Fetch remote repository info (branch heads, domain, default branch).
130 typer.echo(f"Cloning from {url} …")
131 try:
132 info = transport.fetch_remote_info(url, token=None)
133 except TransportError as exc:
134 typer.echo(f"❌ Cannot reach remote: {exc}")
135 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
136
137 remote_repo_id = info["repo_id"] or str(uuid.uuid4())
138 domain = info["domain"] or "midi"
139 default_branch = branch or info["default_branch"] or "main"
140
141 if not info["branch_heads"]:
142 typer.echo("❌ Remote repository has no branches (empty repository).")
143 raise typer.Exit(code=ExitCode.USER_ERROR)
144
145 default_commit_id = info["branch_heads"].get(default_branch)
146 if default_commit_id is None:
147 # Fall back to the first available branch.
148 first_branch, default_commit_id = next(iter(info["branch_heads"].items()))
149 typer.echo(
150 f" ⚠️ Branch '{default_branch}' not found; checking out '{first_branch}'."
151 )
152 default_branch = first_branch
153
154 # Initialise local repository structure.
155 target.mkdir(parents=True, exist_ok=True)
156 try:
157 _init_muse_dir(target, remote_repo_id, domain, default_branch)
158 except OSError as exc:
159 typer.echo(f"❌ Failed to create repository at '{target}': {exc}")
160 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
161
162 # Fetch full pack (no have — we want everything).
163 want = list(info["branch_heads"].values())
164 try:
165 bundle = transport.fetch_pack(url, token=None, want=want, have=[])
166 except TransportError as exc:
167 typer.echo(f"❌ Fetch failed: {exc}")
168 shutil.rmtree(target, ignore_errors=True)
169 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
170
171 apply_result = apply_pack(target, bundle)
172
173 # Write branch head refs for every remote branch.
174 for b, cid in info["branch_heads"].items():
175 ref_file = target / ".muse" / "refs" / "heads" / b
176 ref_file.parent.mkdir(parents=True, exist_ok=True)
177 ref_file.write_text(cid)
178 set_remote_head("origin", b, cid, target)
179
180 # Configure origin remote and upstream tracking.
181 set_remote("origin", url, target)
182 set_upstream(default_branch, "origin", target)
183
184 # Restore working tree from the default branch HEAD.
185 _restore_working_tree(target, default_commit_id)
186
187 commits_received = len(bundle.get("commits") or [])
188 typer.echo(
189 f"✅ Cloned into '{target_name}' — "
190 f"{commits_received} commit(s), {apply_result['objects_written']} object(s), "
191 f"domain={domain}, branch={default_branch} ({default_commit_id[:8]})"
192 )