gabriel / muse public
bundle.py python
402 lines 13.5 KB
00373ad0 feat: migrate CLI from typer to argparse (POSIX-compliant, order-independent) Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """``muse bundle`` — pack and unpack commits for single-file transport.
2
3 A bundle is a self-contained JSON file carrying commits, snapshots, and
4 objects. It is the porcelain equivalent of ``muse plumbing pack-objects`` /
5 ``unpack-objects``, with friendlier names and the key value-add of
6 auto-updating local branch refs after ``unbundle``.
7
8 Use bundles to transfer a repository slice between machines without a network
9 connection — copy the file over SSH, USB, or email.
10
11 Bundle format: identical to the plumbing ``PackBundle`` JSON (same schema).
12 The format is stable and human-inspectable.
13
14 Subcommands::
15
16 muse bundle create <file> [<ref>...] [--have <id>...]
17 muse bundle unbundle <file>
18 muse bundle verify <file> [-q]
19 muse bundle list-heads <file>
20
21 Exit codes::
22
23 0 — success
24 1 — bundle not found, integrity failure, bad arguments
25 3 — I/O error
26 """
27
28 from __future__ import annotations
29
30 import argparse
31 import base64
32 import hashlib
33 import json
34 import logging
35 import pathlib
36 import sys
37
38 from muse.core.errors import ExitCode
39 from muse.core.object_store import has_object, write_object
40 from muse.core.pack import PackBundle, apply_pack, build_pack
41 from muse.core.repo import require_repo
42 from muse.core.store import (
43 CommitRecord,
44 SnapshotRecord,
45 get_head_commit_id,
46 read_current_branch,
47 resolve_commit_ref,
48 write_commit,
49 write_snapshot,
50 )
51 from muse.core.validation import sanitize_display, validate_branch_name
52
53 logger = logging.getLogger(__name__)
54
55
56 def _read_repo_id(root: pathlib.Path) -> str:
57 return str(json.loads((root / ".muse" / "repo.json").read_text(encoding="utf-8"))["repo_id"])
58
59
60 def _resolve_refs(
61 root: pathlib.Path,
62 repo_id: str,
63 branch: str,
64 refs: list[str],
65 ) -> list[str]:
66 """Resolve a list of ref strings to commit IDs. Expands HEAD."""
67 ids: list[str] = []
68 for ref in refs:
69 if ref.upper() == "HEAD":
70 cid = get_head_commit_id(root, branch)
71 if cid:
72 ids.append(cid)
73 else:
74 rec = resolve_commit_ref(root, repo_id, branch, ref)
75 if rec:
76 ids.append(rec.commit_id)
77 else:
78 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
79 raise SystemExit(ExitCode.USER_ERROR)
80 return ids
81
82
83 def _load_bundle(file_path: pathlib.Path) -> PackBundle:
84 try:
85 raw = file_path.read_text(encoding="utf-8")
86 parsed = json.loads(raw)
87 except FileNotFoundError:
88 print(f"❌ Bundle file not found: {file_path}", file=sys.stderr)
89 raise SystemExit(ExitCode.USER_ERROR)
90 except json.JSONDecodeError as exc:
91 print(f"❌ Bundle is not valid JSON: {exc}", file=sys.stderr)
92 raise SystemExit(ExitCode.USER_ERROR)
93
94 if not isinstance(parsed, dict):
95 print("❌ Bundle has unexpected structure.", file=sys.stderr)
96 raise SystemExit(ExitCode.USER_ERROR)
97
98 bundle: PackBundle = {}
99 if "commits" in parsed and isinstance(parsed["commits"], list):
100 bundle["commits"] = parsed["commits"]
101 if "snapshots" in parsed and isinstance(parsed["snapshots"], list):
102 bundle["snapshots"] = parsed["snapshots"]
103 if "objects" in parsed and isinstance(parsed["objects"], list):
104 bundle["objects"] = parsed["objects"]
105 if "branch_heads" in parsed and isinstance(parsed["branch_heads"], dict):
106 bundle["branch_heads"] = {
107 k: v for k, v in parsed["branch_heads"].items()
108 if isinstance(k, str) and isinstance(v, str)
109 }
110 return bundle
111
112
113 def _iter_branches(root: pathlib.Path) -> list[tuple[str, str]]:
114 heads_dir = root / ".muse" / "refs" / "heads"
115 if not heads_dir.exists():
116 return []
117 result: list[tuple[str, str]] = []
118 for ref_file in sorted(heads_dir.rglob("*")):
119 if ref_file.is_file():
120 branch_name = str(ref_file.relative_to(heads_dir).as_posix())
121 cid = ref_file.read_text(encoding="utf-8").strip()
122 if cid:
123 result.append((branch_name, cid))
124 return result
125
126
127 def _reachable_from(root: pathlib.Path, tip_ids: list[str]) -> set[str]:
128 from collections import deque
129 from muse.core.store import read_commit as _rc
130 seen: set[str] = set()
131 q: deque[str] = deque(tip_ids)
132 while q:
133 cid = q.popleft()
134 if cid in seen:
135 continue
136 seen.add(cid)
137 c = _rc(root, cid)
138 if c:
139 if c.parent_commit_id:
140 q.append(c.parent_commit_id)
141 if c.parent2_commit_id:
142 q.append(c.parent2_commit_id)
143 return seen
144
145
146 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
147 """Register the bundle subcommand."""
148 parser = subparsers.add_parser(
149 "bundle",
150 help="Pack and unpack commits into a single portable bundle file.",
151 description=__doc__,
152 )
153 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
154 subs.required = True
155
156 # create
157 create_p = subs.add_parser("create", help="Create a bundle file containing commits reachable from <refs>.")
158 create_p.add_argument("file", help="Output bundle file path.")
159 create_p.add_argument("refs", nargs="*", default=None, help="Refs to include (default: HEAD).")
160 create_p.add_argument(
161 "--have", "-H", nargs="*", default=None, dest="have",
162 help="Commits the receiver already has (exclude from bundle).",
163 )
164 create_p.set_defaults(func=run_create)
165
166 # unbundle
167 unbundle_p = subs.add_parser("unbundle", help="Apply a bundle to the local store and optionally advance branch refs.")
168 unbundle_p.add_argument("file", help="Bundle file to apply.")
169 unbundle_p.add_argument(
170 "--no-update-refs", action="store_false", dest="update_refs",
171 help="Do not update local branch refs from the bundle's branch_heads.",
172 )
173 unbundle_p.set_defaults(func=run_unbundle, update_refs=True)
174
175 # verify
176 verify_p = subs.add_parser("verify", help="Verify the integrity of a bundle file.")
177 verify_p.add_argument("file", help="Bundle file to verify.")
178 verify_p.add_argument(
179 "--quiet", "-q", action="store_true", dest="quiet",
180 help="No output — exit 0 if clean, 1 on failure.",
181 )
182 verify_p.add_argument(
183 "--format", "-f", default="text", dest="fmt",
184 help="Output format: text or json.",
185 )
186 verify_p.set_defaults(func=run_verify)
187
188 # list-heads
189 list_heads_p = subs.add_parser("list-heads", help="List the branch heads recorded in a bundle file.")
190 list_heads_p.add_argument("file", help="Bundle file to inspect.")
191 list_heads_p.add_argument(
192 "--format", "-f", default="text", dest="fmt",
193 help="Output format: text or json.",
194 )
195 list_heads_p.set_defaults(func=run_list_heads)
196
197
198 def run_create(args: argparse.Namespace) -> None:
199 """Create a bundle file containing commits reachable from <refs>.
200
201 ``--have`` prunes commits the receiver already has, reducing bundle size.
202 The output file is self-contained JSON — safe to copy, email, or sneak-net.
203
204 Examples::
205
206 muse bundle create repo.bundle # HEAD → bundle
207 muse bundle create out.bundle feat/audio # specific branch
208 muse bundle create out.bundle HEAD --have old-sha
209 """
210 file: str = args.file
211 refs: list[str] | None = args.refs
212 have: list[str] | None = args.have
213
214 root = require_repo()
215 repo_id = _read_repo_id(root)
216 branch = read_current_branch(root)
217
218 want_refs: list[str] = refs or ["HEAD"]
219 commit_ids = _resolve_refs(root, repo_id, branch, want_refs)
220
221 if not commit_ids:
222 print("❌ No commits to bundle.", file=sys.stderr)
223 raise SystemExit(ExitCode.USER_ERROR)
224
225 have_ids: list[str] = have or []
226
227 bundle = build_pack(root, commit_ids, have=have_ids)
228
229 # Add branch_heads for the resolved refs.
230 heads: dict[str, str] = {}
231 for br_name, cid in _iter_branches(root):
232 if cid in commit_ids or cid in _reachable_from(root, commit_ids):
233 heads[br_name] = cid
234 if heads:
235 bundle["branch_heads"] = heads
236
237 out_path = pathlib.Path(file)
238 out_path.write_text(json.dumps(bundle, indent=2), encoding="utf-8")
239
240 n_commits = len(bundle.get("commits", []))
241 n_objects = len(bundle.get("objects", []))
242 size_kb = out_path.stat().st_size / 1024
243 print(
244 f"✅ Bundle: {out_path} ({n_commits} commits, {n_objects} objects, {size_kb:.1f} KiB)"
245 )
246
247
248 def run_unbundle(args: argparse.Namespace) -> None:
249 """Apply a bundle to the local store and optionally advance branch refs.
250
251 This is the key porcelain value-add over ``muse plumbing unpack-objects``:
252 after unpacking, branch refs are updated from ``branch_heads`` in the bundle
253 so the local repo reflects the sender's branch state.
254
255 Examples::
256
257 muse bundle unbundle repo.bundle
258 muse bundle unbundle repo.bundle --no-update-refs
259 """
260 file: str = args.file
261 update_refs: bool = args.update_refs
262
263 root = require_repo()
264 bundle = _load_bundle(pathlib.Path(file))
265
266 result = apply_pack(root, bundle)
267
268 print(
269 f"Unpacked {result['commits_written']} commit(s), "
270 f"{result['snapshots_written']} snapshot(s), "
271 f"{result['objects_written']} object(s) "
272 f"({result['objects_skipped']} skipped)"
273 )
274
275 if update_refs:
276 branch_heads: dict[str, str] = bundle.get("branch_heads") or {}
277 updated: list[str] = []
278 for br, cid in branch_heads.items():
279 try:
280 validate_branch_name(br)
281 except ValueError:
282 logger.warning("⚠️ bundle: skipping invalid branch name %r", br)
283 continue
284 if len(cid) != 64 or not all(c in "0123456789abcdef" for c in cid):
285 logger.warning("⚠️ bundle: skipping invalid commit ID for %r", br)
286 continue
287 ref_path = root / ".muse" / "refs" / "heads" / br
288 ref_path.parent.mkdir(parents=True, exist_ok=True)
289 ref_path.write_text(cid, encoding="utf-8")
290 updated.append(br)
291
292 if updated:
293 print(f"Updated refs: {', '.join(sanitize_display(b) for b in updated)}")
294
295 print("✅ Bundle applied.")
296
297
298 def run_verify(args: argparse.Namespace) -> None:
299 """Verify the integrity of a bundle file.
300
301 Checks that every object's SHA-256 matches its declared ``object_id``
302 (hash mismatch → corruption). Also checks that every snapshot's objects
303 are present in the bundle.
304
305 Examples::
306
307 muse bundle verify repo.bundle
308 muse bundle verify repo.bundle --quiet && echo "clean"
309 """
310 file: str = args.file
311 quiet: bool = args.quiet
312 fmt: str = args.fmt
313
314 if fmt not in {"text", "json"}:
315 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr)
316 raise SystemExit(ExitCode.USER_ERROR)
317
318 bundle = _load_bundle(pathlib.Path(file))
319
320 failures: list[str] = []
321 objects_checked = 0
322
323 # Build set of object IDs in the bundle.
324 bundle_obj_ids: set[str] = set()
325 for obj in bundle.get("objects", []):
326 obj_id = obj["object_id"]
327 content_b64 = obj["content_b64"]
328 if not obj_id or not content_b64:
329 failures.append("objects list: entry has empty object_id or content_b64")
330 continue
331 try:
332 raw = base64.b64decode(content_b64)
333 except Exception:
334 failures.append(f"object {obj_id[:12]}: base64 decode error")
335 objects_checked += 1
336 continue
337 actual = hashlib.sha256(raw).hexdigest()
338 if actual != obj_id:
339 failures.append(f"object {obj_id[:12]}: hash mismatch (corruption)")
340 else:
341 bundle_obj_ids.add(obj_id)
342 objects_checked += 1
343
344 # Check snapshots reference objects in the bundle.
345 for snap_dict in bundle.get("snapshots", []):
346 snap_id = snap_dict.get("snapshot_id", "")
347 manifest = snap_dict.get("manifest", {})
348 for rel_path, obj_id in manifest.items():
349 if obj_id not in bundle_obj_ids:
350 failures.append(
351 f"snapshot {snap_id[:12]}: missing object {obj_id[:12]} for {rel_path}"
352 )
353
354 all_ok = len(failures) == 0
355
356 if quiet:
357 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
358
359 if fmt == "json":
360 print(json.dumps({
361 "objects_checked": objects_checked,
362 "all_ok": all_ok,
363 "failures": failures,
364 }, indent=2))
365 else:
366 print(f"Objects checked: {objects_checked}")
367 if all_ok:
368 print("✅ Bundle is clean.")
369 else:
370 print(f"❌ {len(failures)} failure(s):")
371 for f in failures:
372 print(f" {f}")
373
374 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
375
376
377 def run_list_heads(args: argparse.Namespace) -> None:
378 """List the branch heads recorded in a bundle file.
379
380 Examples::
381
382 muse bundle list-heads repo.bundle
383 muse bundle list-heads repo.bundle --format json
384 """
385 file: str = args.file
386 fmt: str = args.fmt
387
388 if fmt not in {"text", "json"}:
389 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr)
390 raise SystemExit(ExitCode.USER_ERROR)
391
392 bundle = _load_bundle(pathlib.Path(file))
393 heads: dict[str, str] = bundle.get("branch_heads") or {}
394
395 if fmt == "json":
396 print(json.dumps(heads, indent=2))
397 else:
398 if not heads:
399 print("No branch heads in bundle.")
400 return
401 for branch, cid in sorted(heads.items()):
402 print(f"{cid[:12]} {sanitize_display(branch)}")