bundle.py python
790 lines 28.3 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """``muse bundle`` — pack and unpack commits for single-file transport.
2
3 A bundle is a self-contained msgpack binary file carrying commits, snapshots,
4 and 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: binary msgpack encoding of the ``PackBundle`` TypedDict.
12 Objects are stored as raw bytes (no base64 overhead).
13
14 Subcommands::
15
16 muse bundle create <file> [<ref>...] [--have <id>...] [--json]
17 muse bundle unbundle <file> [--no-update-refs] [--json]
18 muse bundle verify <file> [-q] [--json]
19 muse bundle list-heads <file> [--json]
20
21 Security model::
22
23 Symlinks inside ``.muse/refs/heads/`` are silently skipped during branch
24 enumeration. A crafted symlink could otherwise expose external path content.
25
26 Ref files are read at most 65 bytes; oversized files are treated as invalid
27 refs rather than read entirely into memory.
28
29 Bundle file parsing is limited by ``MAX_PACK_MSGPACK_BYTES``; both the file
30 size and the msgpack payload are checked before any deserialization occurs.
31
32 JSON output schemas::
33
34 bundle create --json:
35 {"file": str, "commits": int, "objects": int,
36 "size_bytes": int, "branches": [str, ...]}
37
38 bundle unbundle --json:
39 {"commits_written": int, "snapshots_written": int,
40 "objects_written": int, "objects_skipped": int,
41 "refs_updated": [str, ...]}
42
43 bundle verify --json:
44 {"objects_checked": int, "snapshots_checked": int,
45 "all_ok": bool, "failures": [str, ...]}
46
47 bundle list-heads --json:
48 {"<branch>": "<commit_id>", ...}
49
50 Exit codes::
51
52 0 — success
53 1 — bundle not found, integrity failure, bad arguments
54 3 — I/O error
55 """
56
57 from __future__ import annotations
58
59 import argparse
60 import hashlib
61 import json
62 import logging
63 import os
64 import pathlib
65 import sys
66 import tempfile
67 from collections import deque
68 from typing import TypedDict, TypeGuard
69
70 import msgpack
71
72 from muse.core.errors import ExitCode
73 from muse.core.object_store import has_object, write_object
74 from muse.core.pack import PackBundle, apply_pack, build_pack
75 from muse.core.repo import read_repo_id, require_repo
76 from muse.core.store import (
77 MAX_PACK_MSGPACK_BYTES,
78 BranchHeads,
79 CommitDict,
80 CommitRecord,
81 MsgpackValue,
82 SnapshotDict,
83 SnapshotRecord,
84 get_head_commit_id,
85 read_commit,
86 read_current_branch,
87 resolve_commit_ref,
88 safe_unpackb,
89 write_branch_ref,
90 write_commit,
91 write_snapshot,
92 )
93 from muse.core.validation import sanitize_display, validate_branch_name
94
95 logger = logging.getLogger(__name__)
96
97 # Maximum bytes read from a branch ref file. A valid SHA-256 hex string is
98 # 64 chars + optional newline = 65 bytes. Anything larger is treated as
99 # corrupt.
100 _MAX_REF_BYTES = 65
101
102 # Maximum number of distinct failures collected during ``muse bundle verify``.
103 # Caps memory and output size when a crafted bundle has many corrupted objects.
104 _MAX_VERIFY_FAILURES = 100
105
106
107 # ---------------------------------------------------------------------------
108 # JSON wire-format TypedDicts
109 # ---------------------------------------------------------------------------
110
111
112 class _BundleCreateJson(TypedDict):
113 """JSON output for ``muse bundle create --json``."""
114
115 file: str
116 commits: int
117 objects: int
118 size_bytes: int
119 branches: list[str]
120
121
122 class _BundleUnbundleJson(TypedDict):
123 """JSON output for ``muse bundle unbundle --json``."""
124
125 commits_written: int
126 snapshots_written: int
127 objects_written: int
128 objects_skipped: int
129 refs_updated: list[str]
130
131
132 class _BundleVerifyJson(TypedDict):
133 """JSON output for ``muse bundle verify --json``."""
134
135 objects_checked: int
136 snapshots_checked: int
137 all_ok: bool
138 failures: list[str]
139
140
141 # ---------------------------------------------------------------------------
142 # TypeGuards for wire-boundary narrowing
143 # ---------------------------------------------------------------------------
144
145
146 def _is_commit_dict(v: MsgpackValue) -> TypeGuard[CommitDict]:
147 """TypeGuard for narrowing MsgpackValue → CommitDict at the wire boundary."""
148 return isinstance(v, dict)
149
150
151 def _is_snapshot_dict(v: MsgpackValue) -> TypeGuard[SnapshotDict]:
152 """TypeGuard for narrowing MsgpackValue → SnapshotDict at the wire boundary."""
153 return isinstance(v, dict)
154
155
156 # ---------------------------------------------------------------------------
157 # Internal helpers
158 # ---------------------------------------------------------------------------
159
160
161 def _resolve_refs(
162 root: pathlib.Path,
163 repo_id: str,
164 branch: str,
165 refs: list[str],
166 ) -> list[str]:
167 """Resolve a list of ref strings to commit IDs. Expands ``HEAD``."""
168 ids: list[str] = []
169 for ref in refs:
170 if ref.upper() == "HEAD":
171 cid = get_head_commit_id(root, branch)
172 if cid:
173 ids.append(cid)
174 else:
175 rec = resolve_commit_ref(root, repo_id, branch, ref)
176 if rec:
177 ids.append(rec.commit_id)
178 else:
179 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
180 raise SystemExit(ExitCode.USER_ERROR)
181 return ids
182
183
184 def _load_bundle(file_path: pathlib.Path) -> PackBundle:
185 """Read a msgpack bundle file and return a :class:`~muse.core.pack.PackBundle`.
186
187 Security:
188 The file size is checked before reading. The raw bytes are then
189 passed through :func:`~muse.core.store.safe_unpackb` which enforces
190 an upper bound on the deserialized payload as well. This double-check
191 prevents OOM from crafted size-bomb bundles.
192
193 Exceptions are narrowed to :class:`(OSError, ValueError,
194 msgpack.UnpackException)` — the precise types raised by the I/O and
195 parse path — so genuine programming errors are not silently swallowed.
196 """
197 try:
198 size = file_path.stat().st_size
199 if size > MAX_PACK_MSGPACK_BYTES:
200 raise OSError(
201 f"Bundle file is {size:,} bytes — exceeds the "
202 f"{MAX_PACK_MSGPACK_BYTES // (1024 * 1024)} MiB safety cap."
203 )
204 raw_bytes = file_path.read_bytes()
205 parsed = safe_unpackb(
206 raw_bytes,
207 context="bundle file",
208 max_bytes=MAX_PACK_MSGPACK_BYTES,
209 allow_binary=True,
210 )
211 except FileNotFoundError:
212 print(
213 f"❌ Bundle file not found: {sanitize_display(str(file_path))}",
214 file=sys.stderr,
215 )
216 raise SystemExit(ExitCode.USER_ERROR)
217 except (OSError, ValueError, msgpack.UnpackException) as exc:
218 print(f"❌ Bundle is not valid msgpack: {sanitize_display(str(exc))}", file=sys.stderr)
219 raise SystemExit(ExitCode.USER_ERROR)
220
221 if not isinstance(parsed, dict):
222 print("❌ Bundle has unexpected structure.", file=sys.stderr)
223 raise SystemExit(ExitCode.USER_ERROR)
224
225 from muse.core.pack import ObjectPayload
226
227 bundle: PackBundle = {}
228 raw_commits = parsed.get("commits")
229 if isinstance(raw_commits, list):
230 bundle["commits"] = [r for r in raw_commits if _is_commit_dict(r)]
231 raw_snapshots = parsed.get("snapshots")
232 if isinstance(raw_snapshots, list):
233 bundle["snapshots"] = [r for r in raw_snapshots if _is_snapshot_dict(r)]
234 if "objects" in parsed and isinstance(parsed["objects"], list):
235 objects: list[ObjectPayload] = []
236 for item in parsed["objects"]:
237 if isinstance(item, dict):
238 oid = item.get("object_id")
239 content = item.get("content")
240 if isinstance(oid, str) and isinstance(content, (bytes, bytearray)):
241 objects.append(ObjectPayload(object_id=oid, content=bytes(content)))
242 bundle["objects"] = objects
243 if "branch_heads" in parsed and isinstance(parsed["branch_heads"], dict):
244 bundle["branch_heads"] = {
245 k: v
246 for k, v in parsed["branch_heads"].items()
247 if isinstance(k, str) and isinstance(v, str)
248 }
249 return bundle
250
251
252 def _iter_branches(root: pathlib.Path) -> list[tuple[str, str]]:
253 """Return ``[(branch_name, commit_id)]`` for all branch ref files.
254
255 Security:
256 Symlinks inside ``.muse/refs/heads/`` are silently skipped.
257 Ref files are capped at ``_MAX_REF_BYTES`` (65 bytes) before
258 decoding — oversized content is decoded but will fail hex
259 validation downstream.
260 """
261 heads_dir = root / ".muse" / "refs" / "heads"
262 if not heads_dir.exists():
263 return []
264 result: list[tuple[str, str]] = []
265 for ref_file in sorted(heads_dir.rglob("*")):
266 if ref_file.is_symlink():
267 logger.warning("⚠️ bundle: skipping symlink ref: %s", ref_file)
268 continue
269 if not ref_file.is_file():
270 continue
271 raw_bytes = ref_file.read_bytes()[:_MAX_REF_BYTES]
272 cid = raw_bytes.decode("utf-8", errors="replace").strip()
273 if cid:
274 branch_name = str(ref_file.relative_to(heads_dir).as_posix())
275 result.append((branch_name, cid))
276 return result
277
278
279 def _reachable_from(root: pathlib.Path, tip_ids: list[str]) -> set[str]:
280 """BFS-walk from *tip_ids* and return all reachable commit IDs."""
281 seen: set[str] = set()
282 q: deque[str] = deque(tip_ids)
283 while q:
284 cid = q.popleft()
285 if cid in seen:
286 continue
287 seen.add(cid)
288 c = read_commit(root, cid)
289 if c:
290 if c.parent_commit_id:
291 q.append(c.parent_commit_id)
292 if c.parent2_commit_id:
293 q.append(c.parent2_commit_id)
294 return seen
295
296
297 # ---------------------------------------------------------------------------
298 # Registration
299 # ---------------------------------------------------------------------------
300
301
302 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
303 """Register the bundle subcommand."""
304 parser = subparsers.add_parser(
305 "bundle",
306 help="Pack and unpack commits into a single portable bundle file.",
307 description=__doc__,
308 formatter_class=argparse.RawDescriptionHelpFormatter,
309 )
310 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
311 subs.required = True
312
313 # create
314 create_p = subs.add_parser(
315 "create",
316 help="Create a bundle file containing commits reachable from <refs>.",
317 description=(
318 "Pack commits, snapshots, and objects reachable from <refs> into a\n"
319 "single self-contained msgpack binary file. Transfer it over SSH,\n"
320 "USB, or email and apply it with ``muse bundle unbundle``.\n\n"
321 "Use --have to prune commits the receiver already has, producing a\n"
322 "smaller incremental bundle.\n\n"
323 "Agent quickstart\n"
324 "----------------\n"
325 " muse bundle create repo.bundle --json\n"
326 " muse bundle create out.bundle feat/audio --json\n"
327 " muse bundle create out.bundle HEAD --have <old-sha> --json\n\n"
328 "JSON output schema\n"
329 "------------------\n"
330 ' {"file": "<path>", "commits": <int>, "objects": <int>,\n'
331 ' "size_bytes": <int>, "branches": ["<branch>", ...]}\n\n'
332 "Exit codes\n"
333 "----------\n"
334 " 0 — bundle created successfully\n"
335 " 1 — unknown ref, no commits to bundle, or bad --have ref\n"
336 " 2 — not inside a Muse repository\n"
337 ),
338 formatter_class=argparse.RawDescriptionHelpFormatter,
339 )
340 create_p.add_argument("file", help="Output bundle file path.")
341 create_p.add_argument(
342 "refs", nargs="*", default=None,
343 help="Refs to include (default: HEAD).",
344 )
345 create_p.add_argument(
346 "--have", "-H", nargs="*", default=None, dest="have",
347 help="Commits the receiver already has (exclude from bundle).",
348 )
349 create_p.add_argument(
350 "--json", "-j", action="store_true", dest="json_out",
351 help="Emit a machine-readable JSON summary.",
352 )
353 create_p.set_defaults(func=run_create)
354
355 # list-heads
356 list_heads_p = subs.add_parser(
357 "list-heads",
358 help="List the branch heads recorded in a bundle file.",
359 description=(
360 "Print the branch → commit_id map embedded in a bundle file.\n"
361 "Does NOT require a Muse repository — runs against any bundle\n"
362 "file in isolation.\n\n"
363 "Agent quickstart\n"
364 "----------------\n"
365 " muse bundle list-heads repo.bundle --json\n"
366 " muse bundle list-heads repo.bundle --json | jq 'keys'\n\n"
367 "JSON output schema\n"
368 "------------------\n"
369 ' {"<branch>": "<commit_id_64_hex>", ...}\n\n'
370 "Exit codes\n"
371 "----------\n"
372 " 0 — always (empty object when no heads are recorded)\n"
373 " 1 — bundle file not found or not valid msgpack\n"
374 ),
375 formatter_class=argparse.RawDescriptionHelpFormatter,
376 )
377 list_heads_p.add_argument("file", help="Bundle file to inspect.")
378 list_heads_p.add_argument(
379 "--json", "-j", action="store_true", dest="json_out",
380 help="Emit a machine-readable JSON map of branch → commit_id.",
381 )
382 list_heads_p.set_defaults(func=run_list_heads)
383
384 # unbundle
385 unbundle_p = subs.add_parser(
386 "unbundle",
387 help="Apply a bundle to the local store and optionally advance branch refs.",
388 description=(
389 "Write all commits, snapshots, and objects from a bundle file into\n"
390 "the local repository, then advance local branch refs to match the\n"
391 "bundle's recorded heads. All writes are idempotent — objects\n"
392 "already present are counted as 'skipped', not errors.\n\n"
393 "Use --no-update-refs to import objects without moving any branches\n"
394 "(useful for inspection or partial imports).\n\n"
395 "Agent quickstart\n"
396 "----------------\n"
397 " muse bundle unbundle repo.bundle --json\n"
398 " muse bundle unbundle repo.bundle --no-update-refs --json\n\n"
399 "JSON output schema\n"
400 "------------------\n"
401 ' {"commits_written": <int>, "snapshots_written": <int>,\n'
402 ' "objects_written": <int>, "objects_skipped": <int>,\n'
403 ' "refs_updated": ["<branch>", ...]}\n\n'
404 "Exit codes\n"
405 "----------\n"
406 " 0 — bundle applied (even if all objects were already present)\n"
407 " 1 — bundle file not found or not valid msgpack\n"
408 " 2 — not inside a Muse repository\n"
409 ),
410 formatter_class=argparse.RawDescriptionHelpFormatter,
411 )
412 unbundle_p.add_argument("file", help="Bundle file to apply.")
413 unbundle_p.add_argument(
414 "--no-update-refs", action="store_false", dest="update_refs",
415 help="Do not update local branch refs from the bundle's branch_heads.",
416 )
417 unbundle_p.add_argument(
418 "--json", "-j", action="store_true", dest="json_out",
419 help="Emit a machine-readable JSON summary.",
420 )
421 unbundle_p.set_defaults(func=run_unbundle, update_refs=True)
422
423 # verify
424 verify_p = subs.add_parser(
425 "verify",
426 help="Verify the integrity of a bundle file.",
427 description=(
428 "Check every object's SHA-256 against its declared object_id\n"
429 "(detects corruption), and confirm every snapshot's objects are\n"
430 "present in the bundle (detects truncation). Does NOT require a\n"
431 "Muse repository — runs against any bundle file in isolation.\n\n"
432 "Use --quiet for scripting: no output, just the exit code.\n\n"
433 "Agent quickstart\n"
434 "----------------\n"
435 " muse bundle verify repo.bundle --json\n"
436 " muse bundle verify repo.bundle -q && muse bundle unbundle repo.bundle\n\n"
437 "JSON output schema\n"
438 "------------------\n"
439 ' {"objects_checked": <int>, "snapshots_checked": <int>,\n'
440 ' "all_ok": true|false, "failures": ["<description>", ...]}\n\n'
441 "Exit codes\n"
442 "----------\n"
443 " 0 — bundle is clean\n"
444 " 1 — integrity failures, or bundle file not found / not valid\n"
445 ),
446 formatter_class=argparse.RawDescriptionHelpFormatter,
447 )
448 verify_p.add_argument("file", help="Bundle file to verify.")
449 verify_p.add_argument(
450 "--quiet", "-q", action="store_true", dest="quiet",
451 help="No output — exit 0 if clean, 1 on failure.",
452 )
453 verify_p.add_argument(
454 "--json", "-j", action="store_true", dest="json_out",
455 help="Emit a machine-readable JSON report.",
456 )
457 verify_p.set_defaults(func=run_verify)
458
459
460 # ---------------------------------------------------------------------------
461 # Subcommand handlers
462 # ---------------------------------------------------------------------------
463
464
465 def run_create(args: argparse.Namespace) -> None:
466 """Create a bundle file containing commits reachable from <refs>.
467
468 A bundle is a self-contained **msgpack binary** file carrying commits,
469 snapshots, and objects. Copy it over SSH, USB, or email, then apply it
470 on the receiving end with ``muse bundle unbundle``.
471
472 ``--have`` prunes commits the receiver already has, reducing bundle size.
473 Pass the tip commit ID(s) of the receiver's matching branch.
474
475 The output file is written atomically — a temporary file in the same
476 directory is renamed over the target, so a kill signal during write never
477 leaves a partial bundle at the output path.
478
479 JSON schema (``--json``)::
480
481 {
482 "file": "<path>",
483 "commits": <int>,
484 "objects": <int>,
485 "size_bytes": <int>,
486 "branches": ["<branch>", ...]
487 }
488
489 Exit codes:
490 0 — bundle created successfully
491 1 — unknown ref, no commits to bundle, or bad --have ref
492 2 — not inside a Muse repository
493
494 Examples::
495
496 muse bundle create repo.bundle # HEAD → bundle
497 muse bundle create out.bundle feat/audio # specific branch
498 muse bundle create out.bundle HEAD --have old-sha
499 muse bundle create repo.bundle --json
500 """
501 file: str = args.file
502 refs: list[str] | None = args.refs
503 have: list[str] | None = args.have
504 json_out: bool = args.json_out
505
506 root = require_repo()
507 repo_id = read_repo_id(root)
508 branch = read_current_branch(root)
509
510 want_refs: list[str] = refs or ["HEAD"]
511 commit_ids = _resolve_refs(root, repo_id, branch, want_refs)
512
513 if not commit_ids:
514 print("❌ No commits to bundle.", file=sys.stderr)
515 raise SystemExit(ExitCode.USER_ERROR)
516
517 have_ids: list[str] = have or []
518 bundle = build_pack(root, commit_ids, have=have_ids)
519
520 # Pre-compute the reachable set once (O(commits)) so the branch-head
521 # filter below is O(branches) rather than O(branches × commits).
522 reachable: set[str] = _reachable_from(root, commit_ids)
523 heads: BranchHeads = {}
524 for br_name, cid in _iter_branches(root):
525 if cid in commit_ids or cid in reachable:
526 heads[br_name] = cid
527 if heads:
528 bundle["branch_heads"] = heads
529
530 out_path = pathlib.Path(file)
531 packed = msgpack.packb(bundle, use_bin_type=True)
532
533 # Atomic write: temp file in same directory → os.replace().
534 # Ensures a kill signal never leaves a partial bundle at out_path.
535 tmp_dir = out_path.parent if out_path.parent != pathlib.Path("") else pathlib.Path(".")
536 tmp_fd, tmp_str = tempfile.mkstemp(dir=tmp_dir, prefix=".bundle-tmp-")
537 tmp_path_obj = pathlib.Path(tmp_str)
538 try:
539 with os.fdopen(tmp_fd, "wb") as fh:
540 fh.write(packed)
541 os.replace(tmp_path_obj, out_path)
542 except Exception:
543 tmp_path_obj.unlink(missing_ok=True)
544 raise
545
546 n_commits = len(bundle.get("commits", []))
547 n_objects = len(bundle.get("objects", []))
548 size_bytes = out_path.stat().st_size
549
550 if json_out:
551 payload: _BundleCreateJson = {
552 "file": str(out_path),
553 "commits": n_commits,
554 "objects": n_objects,
555 "size_bytes": size_bytes,
556 "branches": sorted(heads.keys()),
557 }
558 print(json.dumps(payload))
559 else:
560 size_kb = size_bytes / 1024
561 print(
562 f"✅ Bundle: {sanitize_display(str(out_path))} "
563 f"({n_commits} commits, {n_objects} objects, {size_kb:.1f} KiB)"
564 )
565
566
567 def run_unbundle(args: argparse.Namespace) -> None:
568 """Apply a bundle to the local store and optionally advance branch refs.
569
570 This is the key porcelain value-add over ``muse plumbing unpack-objects``:
571 after unpacking, branch refs are updated from ``branch_heads`` in the
572 bundle so the local repo reflects the sender's branch state.
573
574 Branch names from the bundle are validated with
575 :func:`~muse.core.validation.validate_branch_name` and commit IDs must be
576 exactly 64 lowercase hex characters — invalid entries are skipped with a
577 warning rather than causing the entire operation to fail.
578
579 JSON schema (``--json``)::
580
581 {
582 "commits_written": <int>,
583 "snapshots_written": <int>,
584 "objects_written": <int>,
585 "objects_skipped": <int>,
586 "refs_updated": ["<branch>", ...]
587 }
588
589 Exit codes:
590 0 — bundle applied (idempotent: already-present objects are skipped)
591 1 — bundle file not found or not valid msgpack
592 2 — not inside a Muse repository
593
594 Examples::
595
596 muse bundle unbundle repo.bundle
597 muse bundle unbundle repo.bundle --no-update-refs
598 muse bundle unbundle repo.bundle --json
599 """
600 file: str = args.file
601 update_refs: bool = args.update_refs
602 json_out: bool = args.json_out
603
604 root = require_repo()
605 bundle = _load_bundle(pathlib.Path(file))
606 result = apply_pack(root, bundle)
607
608 updated: list[str] = []
609 if update_refs:
610 branch_heads: BranchHeads = bundle.get("branch_heads") or {}
611 for br, cid in branch_heads.items():
612 try:
613 validate_branch_name(br)
614 except ValueError:
615 logger.warning("⚠️ bundle: skipping invalid branch name %r", br)
616 continue
617 if len(cid) != 64 or not all(c in "0123456789abcdef" for c in cid):
618 logger.warning("⚠️ bundle: skipping invalid commit ID for %r", br)
619 continue
620 write_branch_ref(root, br, cid)
621 updated.append(br)
622
623 if json_out:
624 unbundle_payload: _BundleUnbundleJson = {
625 "commits_written": result["commits_written"],
626 "snapshots_written": result["snapshots_written"],
627 "objects_written": result["objects_written"],
628 "objects_skipped": result["objects_skipped"],
629 "refs_updated": sorted(updated),
630 }
631 print(json.dumps(unbundle_payload))
632 else:
633 print(
634 f"Unpacked {result['commits_written']} commit(s), "
635 f"{result['snapshots_written']} snapshot(s), "
636 f"{result['objects_written']} object(s) "
637 f"({result['objects_skipped']} skipped)"
638 )
639 if updated:
640 print(
641 f"Updated refs: {', '.join(sanitize_display(b) for b in updated)}"
642 )
643 print("✅ Bundle applied.")
644
645
646 def run_verify(args: argparse.Namespace) -> None:
647 """Verify the integrity of a bundle file.
648
649 Checks that every object's SHA-256 matches its declared ``object_id``
650 (hash mismatch → corruption). Also checks that every snapshot's objects
651 are present in the bundle (detects truncation).
652
653 Does NOT require a Muse repository — runs against any bundle file in
654 isolation. This makes it safe to verify a bundle before transferring it
655 to a target repo.
656
657 At most ``_MAX_VERIFY_FAILURES`` (100) distinct failures are recorded.
658 If a bundle has more, a trailing "... and N more" entry is appended so
659 the list stays bounded regardless of bundle size.
660
661 JSON schema (``--json``)::
662
663 {
664 "objects_checked": <int>,
665 "snapshots_checked": <int>,
666 "all_ok": <bool>,
667 "failures": ["<description>", ...]
668 }
669
670 Exit codes:
671 0 — bundle is clean
672 1 — integrity failures found, or bundle file not found / not valid
673
674 Examples::
675
676 muse bundle verify repo.bundle
677 muse bundle verify repo.bundle --quiet && echo "clean"
678 muse bundle verify repo.bundle --json | jq '.failures'
679 """
680 file: str = args.file
681 quiet: bool = args.quiet
682 json_out: bool = args.json_out
683
684 bundle = _load_bundle(pathlib.Path(file))
685
686 failures: list[str] = []
687 objects_checked = 0
688 snapshots_checked = 0
689 _total_failures = 0 # tracks raw count before the cap kicks in
690
691 def _add_failure(msg: str) -> None:
692 nonlocal _total_failures
693 _total_failures += 1
694 if len(failures) < _MAX_VERIFY_FAILURES:
695 failures.append(msg)
696
697 # Build set of object IDs in the bundle and verify each hash.
698 bundle_obj_ids: set[str] = set()
699 for obj in bundle.get("objects", []):
700 obj_id = obj["object_id"]
701 raw = obj["content"]
702 if not obj_id or not raw:
703 _add_failure("objects list: entry has empty object_id or content")
704 continue
705 actual = hashlib.sha256(raw).hexdigest()
706 if actual != obj_id:
707 _add_failure(f"object {obj_id[:12]}: hash mismatch (corruption)")
708 else:
709 bundle_obj_ids.add(obj_id)
710 objects_checked += 1
711
712 # Check snapshots reference objects present in the bundle.
713 for snap_dict in bundle.get("snapshots", []):
714 snap_id = snap_dict.get("snapshot_id", "")
715 manifest = snap_dict.get("manifest", {})
716 snapshots_checked += 1
717 for rel_path, obj_id in manifest.items():
718 if obj_id not in bundle_obj_ids:
719 _add_failure(
720 f"snapshot {snap_id[:12]}: "
721 f"missing object {obj_id[:12]} for {rel_path}"
722 )
723
724 overflow = _total_failures - len(failures)
725 if overflow > 0:
726 failures.append(f"... and {overflow} more failure(s) (cap: {_MAX_VERIFY_FAILURES})")
727
728 all_ok = _total_failures == 0
729
730 if quiet:
731 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
732
733 if json_out:
734 verify_payload: _BundleVerifyJson = {
735 "objects_checked": objects_checked,
736 "snapshots_checked": snapshots_checked,
737 "all_ok": all_ok,
738 "failures": [sanitize_display(f) for f in failures],
739 }
740 print(json.dumps(verify_payload))
741 else:
742 print(f"Objects checked: {objects_checked}")
743 print(f"Snapshots checked: {snapshots_checked}")
744 if all_ok:
745 print("✅ Bundle is clean.")
746 else:
747 print(f"❌ {_total_failures} failure(s):")
748 for f in failures:
749 print(f" {sanitize_display(str(f))}")
750
751 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
752
753
754 def run_list_heads(args: argparse.Namespace) -> None:
755 """List the branch heads recorded in a bundle file.
756
757 JSON output is a plain ``{branch: commit_id}`` map — suitable for piping
758 directly into ``jq`` or agent decision logic.
759
760 Does NOT require a Muse repository — runs against any bundle file in
761 isolation. Both branch names and commit IDs from the bundle are sanitized
762 before output to prevent ANSI injection from crafted bundles.
763
764 Exit codes:
765 0 — always (empty object when no heads are recorded)
766 1 — bundle file not found or not valid msgpack
767
768 Examples::
769
770 muse bundle list-heads repo.bundle
771 muse bundle list-heads repo.bundle --json
772 muse bundle list-heads repo.bundle --json | jq 'keys'
773 """
774 file: str = args.file
775 json_out: bool = args.json_out
776
777 bundle = _load_bundle(pathlib.Path(file))
778 heads: BranchHeads = bundle.get("branch_heads") or {}
779
780 if json_out:
781 safe_heads = {
782 sanitize_display(k): sanitize_display(v) for k, v in heads.items()
783 }
784 print(json.dumps(safe_heads))
785 else:
786 if not heads:
787 print("No branch heads in bundle.")
788 return
789 for branch, cid in sorted(heads.items()):
790 print(f"{sanitize_display(cid[:12])} {sanitize_display(branch)}")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago