gabriel / muse public
bundle.py python
1,123 lines 42.3 KB
Raw
sha256:f8e686793bb93114c2923d0d294162d13b4e6f4d57ae0f6cbc1e0d493e80f965 fix: ls-remote signing identity uses resolved remote URL Sonnet 4.6 patch 12 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 wraps ``muse pack-objects`` / ``unpack-objects`` with friendlier
5 names and the key value-add of auto-updating local branch refs after ``unbundle``.
6
7 Use bundles to transfer a repository slice between machines without a network
8 connection — copy the file over SSH, USB, or email.
9
10 Bundle format: binary msgpack encoding of the ``MPack`` TypedDict.
11 Objects are stored as raw bytes (no base64 overhead).
12
13 Subcommands::
14
15 muse bundle create <file> [<ref>...] [--have <id>...] [--json]
16 muse bundle unbundle <file> [--no-update-refs] [--json]
17 muse bundle verify <file> [-q] [--json]
18 muse bundle list-heads <file> [--json]
19
20 Security model::
21
22 Symlinks inside ``.muse/refs/heads/`` are silently skipped during branch
23 enumeration. A crafted symlink could otherwise expose external path content.
24
25 Ref files are read at most 65 bytes; oversized files are treated as invalid
26 refs rather than read entirely into memory.
27
28 Bundle file parsing is limited by ``MAX_PACK_MSGPACK_BYTES``; both the file
29 size and the msgpack payload are checked before any deserialization occurs.
30
31 JSON output schemas::
32
33 bundle create --json:
34 {"file": str, "commits": int, "blobs": int,
35 "size_bytes": int, "branches": [str, ...]}
36
37 bundle unbundle --json:
38 {"commits_written": int, "snapshots_written": int,
39 "blobs_written": int, "blobs_skipped": int,
40 "refs_updated": [str, ...], "verified": bool}
41
42 bundle verify --json:
43 {"blobs_checked": int, "snapshots_checked": int,
44 "all_ok": bool, "failures": [str, ...]}
45
46 bundle list-heads --json:
47 {"<branch>": "<commit_id>", ...}
48
49 Exit codes::
50
51 0 — success
52 1 — bundle not found, integrity failure, bad arguments
53 3 — I/O error
54 """
55
56 import argparse
57 import json
58 import logging
59 import os
60 import pathlib
61 import sys
62 import tempfile
63 from typing import TypedDict, TypeGuard
64
65 import msgpack
66
67 from muse.core.types import blob_id, short_id
68 from muse.core.paths import heads_dir as _heads_dir
69 from muse.core.envelope import EnvelopeJson, make_envelope
70 from muse.core.timing import start_timer
71 from muse.core.errors import ExitCode
72 from muse.core.object_store import has_object, write_object
73 from muse.core.mpack import MPack, apply_mpack, build_mpack
74 from muse.core.repo import require_repo
75 from muse.core.io import (
76 MAX_PACK_MSGPACK_BYTES,
77 safe_unpackb,
78 )
79 from muse.core.types import (
80 BranchHeads,
81 MsgpackValue,
82 )
83 from muse.core.refs import (
84 get_head_commit_id,
85 read_current_branch,
86 write_branch_ref,
87 )
88 from muse.core.commits import (
89 CommitDict,
90 CommitRecord,
91 read_commit,
92 resolve_commit_ref,
93 write_commit,
94 )
95 from muse.core.snapshots import (
96 SnapshotDict,
97 SnapshotRecord,
98 write_snapshot,
99 )
100 from muse.core.validation import sanitize_display, validate_branch_name
101
102 logger = logging.getLogger(__name__)
103
104 # Maximum bytes read from a branch ref file. A valid canonical commit ID is
105 # "sha256:" (7 chars) + 64 hex chars + optional newline = 72 bytes. Anything
106 # larger is treated as corrupt.
107 _MAX_REF_BYTES = 72
108
109 # Maximum number of distinct failures collected during ``muse bundle verify``.
110 # Caps memory and output size when a crafted bundle has many corrupted objects.
111 _MAX_VERIFY_FAILURES = 100
112
113 # ---------------------------------------------------------------------------
114 # JSON wire-format TypedDicts
115 # ---------------------------------------------------------------------------
116
117 class _BundleCreateJson(EnvelopeJson):
118 """JSON output for ``muse bundle create --json``."""
119
120 file: str
121 commits: int
122 objects: int
123 size_bytes: int
124 branches: list[str]
125
126 class _BundleUnbundleJson(EnvelopeJson):
127 """JSON output for ``muse bundle unbundle --json``."""
128
129 commits_written: int
130 snapshots_written: int
131 blobs_written: int
132 blobs_skipped: int
133 refs_updated: list[str]
134 verified: bool
135
136 class _BundleVerifyJson(EnvelopeJson):
137 """JSON output for ``muse bundle verify --json``."""
138
139 blobs_checked: int
140 snapshots_checked: int
141 all_ok: bool
142 failures: list[str]
143
144 class _BundleInspectCommit(TypedDict):
145 """Per-commit entry in ``muse bundle inspect --json`` output."""
146
147 commit_id: str
148 message: str
149 committed_at: str
150 agent_id: str
151 branches: list[str]
152
153 class _BundleInspectJson(EnvelopeJson):
154 """JSON output for ``muse bundle inspect --json``."""
155
156 total_commits: int
157 total_objects: int
158 branches: dict[str, str] # branch_name → commit_id (sha256:<64hex>)
159 commits: list[_BundleInspectCommit]
160
161 class _BundleDiffCommit(TypedDict):
162 """Per-commit entry in ``muse bundle diff --json`` output."""
163
164 commit_id: str
165 message: str
166 committed_at: str
167
168 class _BundleDiffJson(EnvelopeJson):
169 """JSON output for ``muse bundle diff --json``."""
170
171 new_commits: int
172 known_commits: int
173 refs_to_advance: list[str] # branch names that would move forward
174 commits: list[_BundleDiffCommit] # new commits not yet in local repo
175
176 class _BundleListHeadsJson(EnvelopeJson):
177 """JSON output for ``muse bundle list-heads --json``."""
178
179 heads: dict[str, str] # branch_name → commit_id
180
181 # ---------------------------------------------------------------------------
182 # TypeGuards for wire-boundary narrowing
183 # ---------------------------------------------------------------------------
184
185 def _is_commit_dict(v: MsgpackValue) -> TypeGuard[CommitDict]:
186 """TypeGuard for narrowing MsgpackValue → CommitDict at the wire boundary."""
187 return isinstance(v, dict)
188
189 def _is_snapshot_dict(v: MsgpackValue) -> TypeGuard[SnapshotDict]:
190 """TypeGuard for narrowing MsgpackValue → SnapshotDict at the wire boundary."""
191 return isinstance(v, dict)
192
193 # ---------------------------------------------------------------------------
194 # Internal helpers
195 # ---------------------------------------------------------------------------
196
197 def _resolve_refs(
198 root: pathlib.Path,
199 branch: str,
200 refs: list[str],
201 ) -> list[str]:
202 """Resolve a list of ref strings to commit IDs. Expands ``HEAD``."""
203 ids: list[str] = []
204 for ref in refs:
205 if ref.upper() == "HEAD":
206 cid = get_head_commit_id(root, branch)
207 if cid:
208 ids.append(cid)
209 else:
210 rec = resolve_commit_ref(root, branch, ref)
211 if rec:
212 ids.append(rec.commit_id)
213 else:
214 print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr)
215 raise SystemExit(ExitCode.USER_ERROR)
216 return ids
217
218 def _load_bundle(file_path: pathlib.Path) -> MPack:
219 """Read a msgpack bundle file and return a :class:`~muse.core.mpack.MPack`.
220
221 Security:
222 The file size is checked before reading. The raw bytes are then
223 passed through :func:`~muse.core.store.safe_unpackb` which enforces
224 an upper bound on the deserialized payload as well. This double-check
225 prevents OOM from crafted size-bomb bundles.
226
227 Exceptions are narrowed to :class:`(OSError, ValueError,
228 msgpack.UnpackException)` — the precise types raised by the I/O and
229 parse path — so genuine programming errors are not silently swallowed.
230 """
231 try:
232 size = file_path.stat().st_size
233 if size > MAX_PACK_MSGPACK_BYTES:
234 raise OSError(
235 f"Bundle file is {size:,} bytes — exceeds the "
236 f"{MAX_PACK_MSGPACK_BYTES // (1024 * 1024)} MiB safety cap."
237 )
238 raw_bytes = file_path.read_bytes()
239 parsed = safe_unpackb(
240 raw_bytes,
241 context="bundle file",
242 max_bytes=MAX_PACK_MSGPACK_BYTES,
243 allow_binary=True,
244 )
245 except FileNotFoundError:
246 print(
247 f"❌ Bundle file not found: {sanitize_display(str(file_path))}",
248 file=sys.stderr,
249 )
250 raise SystemExit(ExitCode.USER_ERROR)
251 except (OSError, ValueError, msgpack.UnpackException) as exc:
252 print(f"❌ Bundle is not valid msgpack: {sanitize_display(str(exc))}", file=sys.stderr)
253 raise SystemExit(ExitCode.USER_ERROR)
254
255 if not isinstance(parsed, dict):
256 print("❌ Bundle has unexpected structure.", file=sys.stderr)
257 raise SystemExit(ExitCode.USER_ERROR)
258
259 from muse.core.mpack import BlobPayload
260
261 bundle: MPack = {}
262 raw_commits = parsed.get("commits")
263 if isinstance(raw_commits, list):
264 bundle["commits"] = [r for r in raw_commits if _is_commit_dict(r)]
265 raw_snapshots = parsed.get("snapshots")
266 if isinstance(raw_snapshots, list):
267 bundle["snapshots"] = [r for r in raw_snapshots if _is_snapshot_dict(r)]
268 if "blobs" in parsed and isinstance(parsed["blobs"], list):
269 blobs: list[BlobPayload] = []
270 for item in parsed["blobs"]:
271 if isinstance(item, dict):
272 oid = item.get("object_id")
273 content = item.get("content")
274 if isinstance(oid, str) and isinstance(content, (bytes, bytearray)):
275 blobs.append(BlobPayload(object_id=oid, content=bytes(content)))
276 bundle["blobs"] = blobs
277 if "branch_heads" in parsed and isinstance(parsed["branch_heads"], dict):
278 bundle["branch_heads"] = {
279 k: v
280 for k, v in parsed["branch_heads"].items()
281 if isinstance(k, str) and isinstance(v, str)
282 }
283 return bundle
284
285 def _iter_branches(root: pathlib.Path) -> list[tuple[str, str]]:
286 """Return ``[(branch_name, commit_id)]`` for all branch ref files.
287
288 Security:
289 Symlinks inside ``.muse/refs/heads/`` are silently skipped.
290 Ref files are capped at ``_MAX_REF_BYTES`` (65 bytes) before
291 decoding — oversized content is decoded but will fail hex
292 validation downstream.
293 """
294 heads_dir = _heads_dir(root)
295 if not heads_dir.exists():
296 return []
297 result: list[tuple[str, str]] = []
298 for ref_file in sorted(heads_dir.rglob("*")):
299 if ref_file.is_symlink():
300 logger.warning("⚠️ bundle: skipping symlink ref: %s", ref_file)
301 continue
302 if not ref_file.is_file():
303 continue
304 raw_bytes = ref_file.read_bytes()[:_MAX_REF_BYTES]
305 cid = raw_bytes.decode("utf-8", errors="replace").strip()
306 if cid:
307 branch_name = str(ref_file.relative_to(heads_dir).as_posix())
308 result.append((branch_name, cid))
309 return result
310
311 def _reachable_from(root: pathlib.Path, tip_ids: list[str]) -> set[str]:
312 """Return all commit IDs reachable from *tip_ids*."""
313 from muse.core.graph import ancestor_ids
314 return ancestor_ids(root, tip_ids)
315
316 # ---------------------------------------------------------------------------
317 # Registration
318 # ---------------------------------------------------------------------------
319
320 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
321 """Register the bundle subcommand."""
322 parser = subparsers.add_parser(
323 "bundle",
324 help="Pack and unpack commits into a single portable bundle file.",
325 description=__doc__,
326 formatter_class=argparse.RawDescriptionHelpFormatter,
327 )
328 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
329 subs.required = True
330
331 # create
332 create_p = subs.add_parser(
333 "create",
334 help="Create a bundle file containing commits reachable from <refs>.",
335 description=(
336 "Pack commits, snapshots, and objects reachable from <refs> into a\n"
337 "single self-contained msgpack binary file. Transfer it over SSH,\n"
338 "USB, or email and apply it with ``muse bundle unbundle``.\n\n"
339 "Use --have to prune commits the receiver already has, producing a\n"
340 "smaller incremental bundle.\n\n"
341 "Agent quickstart\n"
342 "----------------\n"
343 " muse bundle create repo.bundle --json\n"
344 " muse bundle create out.bundle feat/audio --json\n"
345 " muse bundle create out.bundle HEAD --have <old-sha> --json\n\n"
346 "JSON output schema\n"
347 "------------------\n"
348 ' {"file": "<path>", "commits": <int>, "blobs": <int>,\n'
349 ' "size_bytes": <int>, "branches": ["<branch>", ...]}\n\n'
350 "Exit codes\n"
351 "----------\n"
352 " 0 — bundle created successfully\n"
353 " 1 — unknown ref, no commits to bundle, or bad --have ref\n"
354 " 2 — not inside a Muse repository\n"
355 ),
356 formatter_class=argparse.RawDescriptionHelpFormatter,
357 )
358 create_p.add_argument("file", help="Output bundle file path.")
359 create_p.add_argument(
360 "refs", nargs="*", default=None,
361 help="Refs to include (default: HEAD).",
362 )
363 create_p.add_argument(
364 "--have", "-H", nargs="*", default=None, dest="have",
365 help="Commits the receiver already has (exclude from bundle).",
366 )
367 create_p.add_argument(
368 "--json", "-j", action="store_true", dest="json_out",
369 help="Emit a machine-readable JSON summary.",
370 )
371 create_p.set_defaults(func=run_create)
372
373 # list-heads
374 list_heads_p = subs.add_parser(
375 "list-heads",
376 help="List the branch heads recorded in a bundle file.",
377 description=(
378 "Print the branch → commit_id map embedded in a bundle file.\n"
379 "Does NOT require a Muse repository — runs against any bundle\n"
380 "file in isolation.\n\n"
381 "Agent quickstart\n"
382 "----------------\n"
383 " muse bundle list-heads repo.bundle --json\n"
384 " muse bundle list-heads repo.bundle --json | jq 'keys'\n\n"
385 "JSON output schema\n"
386 "------------------\n"
387 ' {"<branch>": "sha256:<64hex>", ...}\n\n'
388 "Exit codes\n"
389 "----------\n"
390 " 0 — always (empty object when no heads are recorded)\n"
391 " 1 — bundle file not found or not valid msgpack\n"
392 ),
393 formatter_class=argparse.RawDescriptionHelpFormatter,
394 )
395 list_heads_p.add_argument("file", help="Bundle file to inspect.")
396 list_heads_p.add_argument(
397 "--json", "-j", action="store_true", dest="json_out",
398 help="Emit a machine-readable JSON map of branch → commit_id.",
399 )
400 list_heads_p.set_defaults(func=run_list_heads)
401
402 # unbundle
403 unbundle_p = subs.add_parser(
404 "unbundle",
405 help="Apply a bundle to the local store and optionally advance branch refs.",
406 description=(
407 "Write all commits, snapshots, and objects from a bundle file into\n"
408 "the local repository, then advance local branch refs to match the\n"
409 "bundle's recorded heads. All writes are idempotent — objects\n"
410 "already present are counted as 'skipped', not errors.\n\n"
411 "Use --no-update-refs to import objects without moving any branches\n"
412 "(useful for inspection or partial imports).\n\n"
413 "Agent quickstart\n"
414 "----------------\n"
415 " muse bundle unbundle repo.bundle --json\n"
416 " muse bundle unbundle repo.bundle --no-update-refs --json\n\n"
417 "JSON output schema\n"
418 "------------------\n"
419 ' {"commits_written": <int>, "snapshots_written": <int>,\n'
420 ' "blobs_written": <int>, "blobs_skipped": <int>,\n'
421 ' "refs_updated": ["<branch>", ...]}\n\n'
422 "Exit codes\n"
423 "----------\n"
424 " 0 — bundle applied (even if all objects were already present)\n"
425 " 1 — bundle file not found or not valid msgpack\n"
426 " 2 — not inside a Muse repository\n"
427 ),
428 formatter_class=argparse.RawDescriptionHelpFormatter,
429 )
430 unbundle_p.add_argument("file", help="Bundle file to apply.")
431 unbundle_p.add_argument(
432 "--no-update-refs", action="store_false", dest="update_refs",
433 help="Do not update local branch refs from the bundle's branch_heads.",
434 )
435 unbundle_p.add_argument(
436 "--verify", action="store_true", dest="verify_first",
437 help=(
438 "Verify bundle integrity before applying. Exits 1 without writing "
439 "anything if the bundle is corrupt."
440 ),
441 )
442 unbundle_p.add_argument(
443 "--json", "-j", action="store_true", dest="json_out",
444 help="Emit a machine-readable JSON summary.",
445 )
446 unbundle_p.set_defaults(func=run_unbundle, update_refs=True, verify_first=False)
447
448 # inspect
449 inspect_p = subs.add_parser(
450 "inspect",
451 help="Show the commit log and branch state recorded in a bundle file.",
452 description=(
453 "Read and display commits, branch heads, and agent provenance from\n"
454 "a bundle file without applying it. No Muse repository required —\n"
455 "safe to run before deciding whether to unbundle.\n\n"
456 "Agent quickstart\n"
457 "----------------\n"
458 " muse bundle inspect repo.bundle --json\n"
459 " # → total_commits, branches map, per-commit message + agent_id\n\n"
460 "Typical agent workflow\n"
461 "----------------------\n"
462 " muse bundle inspect bundle.muse --json # what's in it?\n"
463 " muse bundle verify bundle.muse -q # is it clean?\n"
464 " muse bundle diff bundle.muse --json # what's new?\n"
465 " muse bundle unbundle bundle.muse --verify # apply safely\n\n"
466 "JSON output schema\n"
467 "------------------\n"
468 ' {"total_commits": int, "total_objects": int,\n'
469 ' "branches": {"<name>": "sha256:<64hex>"},\n'
470 ' "commits": [{"commit_id": str, "message": str,\n'
471 ' "committed_at": str, "agent_id": str,\n'
472 ' "branches": [str]}, ...]}\n\n'
473 "Exit codes\n"
474 "----------\n"
475 " 0 — bundle read successfully (even if empty)\n"
476 " 1 — bundle file not found or not valid msgpack\n"
477 ),
478 formatter_class=argparse.RawDescriptionHelpFormatter,
479 )
480 inspect_p.add_argument("file", help="Bundle file to inspect.")
481 inspect_p.add_argument(
482 "--json", "-j", action="store_true", dest="json_out",
483 help="Emit a machine-readable JSON summary.",
484 )
485 inspect_p.set_defaults(func=run_inspect)
486
487 # diff
488 diff_p = subs.add_parser(
489 "diff",
490 help="Show which commits in a bundle are not yet in the local repository.",
491 description=(
492 "Compare a bundle's commits against the local repository and report\n"
493 "which ones are new (not yet present). Agents use this to answer\n"
494 "\"what would this bundle add?\" before deciding to apply it.\n\n"
495 "Requires a Muse repository (unlike inspect, verify, list-heads).\n\n"
496 "Agent quickstart\n"
497 "----------------\n"
498 " muse bundle diff repo.bundle --json\n"
499 " # → new_commits, known_commits, refs_to_advance, commit list\n\n"
500 "JSON output schema\n"
501 "------------------\n"
502 ' {"new_commits": int, "known_commits": int,\n'
503 ' "refs_to_advance": ["<branch>", ...],\n'
504 ' "commits": [{"commit_id": str, "message": str,\n'
505 ' "committed_at": str}, ...]}\n\n'
506 "Exit codes\n"
507 "----------\n"
508 " 0 — diff computed (even if new_commits == 0)\n"
509 " 1 — bundle file not found or not valid msgpack\n"
510 " 2 — not inside a Muse repository\n"
511 ),
512 formatter_class=argparse.RawDescriptionHelpFormatter,
513 )
514 diff_p.add_argument("file", help="Bundle file to diff against the local repo.")
515 diff_p.add_argument(
516 "--json", "-j", action="store_true", dest="json_out",
517 help="Emit a machine-readable JSON report.",
518 )
519 diff_p.set_defaults(func=run_diff)
520
521 # verify
522 verify_p = subs.add_parser(
523 "verify",
524 help="Verify the integrity of a bundle file.",
525 description=(
526 "Check every object's SHA-256 against its declared object_id\n"
527 "(detects corruption), and confirm every snapshot's objects are\n"
528 "present in the bundle (detects truncation). Does NOT require a\n"
529 "Muse repository — runs against any bundle file in isolation.\n\n"
530 "Use --quiet for scripting: no output, just the exit code.\n\n"
531 "Agent quickstart\n"
532 "----------------\n"
533 " muse bundle verify repo.bundle --json\n"
534 " muse bundle verify repo.bundle -q && muse bundle unbundle repo.bundle\n\n"
535 "JSON output schema\n"
536 "------------------\n"
537 ' {"blobs_checked": <int>, "snapshots_checked": <int>,\n'
538 ' "all_ok": true|false, "failures": ["<description>", ...]}\n\n'
539 "Exit codes\n"
540 "----------\n"
541 " 0 — bundle is clean\n"
542 " 1 — integrity failures, or bundle file not found / not valid\n"
543 ),
544 formatter_class=argparse.RawDescriptionHelpFormatter,
545 )
546 verify_p.add_argument("file", help="Bundle file to verify.")
547 verify_p.add_argument(
548 "--quiet", "-q", action="store_true", dest="quiet",
549 help="No output — exit 0 if clean, 1 on failure.",
550 )
551 verify_p.add_argument(
552 "--json", "-j", action="store_true", dest="json_out",
553 help="Emit a machine-readable JSON report.",
554 )
555 verify_p.set_defaults(func=run_verify)
556
557 # ---------------------------------------------------------------------------
558 # Subcommand handlers
559 # ---------------------------------------------------------------------------
560
561 def run_create(args: argparse.Namespace) -> None:
562 """Pack commits reachable from <refs> into a portable bundle file.
563
564 A bundle is a self-contained msgpack binary carrying commits, snapshots,
565 and objects. Copy the file over SSH, USB, or email, then apply it with
566 ``muse bundle unbundle``. Use ``--have`` to produce an incremental bundle
567 by pruning commits the receiver already has. The output file is written
568 atomically — a kill signal never leaves a partial bundle at the target path.
569
570 Agent quickstart
571 ----------------
572 ::
573
574 muse bundle create repo.bundle --json
575 muse bundle create out.bundle feat/audio --json
576 muse bundle create out.bundle HEAD --have <old-sha> --json
577
578 JSON fields
579 -----------
580 file Absolute path to the bundle file written.
581 commits Number of commits packed.
582 objects Number of content objects packed.
583 size_bytes Final file size in bytes.
584 branches List of branch names whose tip is included in the bundle.
585
586 Exit codes
587 ----------
588 0 Bundle created successfully.
589 1 Unknown ref, no commits to bundle, or bad ``--have`` ref.
590 2 Not inside a Muse repository.
591 """
592 elapsed = start_timer()
593 file: str = args.file
594 refs: list[str] | None = args.refs
595 have: list[str] | None = args.have
596 json_out: bool = args.json_out
597
598 root = require_repo()
599 branch = read_current_branch(root)
600
601 want_refs: list[str] = refs or ["HEAD"]
602 commit_ids = _resolve_refs(root, branch, want_refs)
603
604 if not commit_ids:
605 print("❌ No commits to bundle.", file=sys.stderr)
606 raise SystemExit(ExitCode.USER_ERROR)
607
608 have_ids: list[str] = have or []
609 bundle = build_mpack(root, commit_ids, have=have_ids)
610
611 # Pre-compute the reachable set once (O(commits)) so the branch-head
612 # filter below is O(branches) rather than O(branches × commits).
613 reachable: set[str] = _reachable_from(root, commit_ids)
614 heads: BranchHeads = {}
615 for br_name, cid in _iter_branches(root):
616 if cid in commit_ids or cid in reachable:
617 heads[br_name] = cid
618 if heads:
619 bundle["branch_heads"] = heads
620
621 out_path = pathlib.Path(file)
622 packed = msgpack.packb(bundle, use_bin_type=True)
623
624 # Atomic write: temp file in same directory → os.replace().
625 # Ensures a kill signal never leaves a partial bundle at out_path.
626 tmp_dir = out_path.parent if out_path.parent != pathlib.Path("") else pathlib.Path(".")
627 tmp_fd, tmp_str = tempfile.mkstemp(dir=tmp_dir, prefix=".bundle-tmp-")
628 tmp_path_obj = pathlib.Path(tmp_str)
629 try:
630 with os.fdopen(tmp_fd, "wb") as fh:
631 fh.write(packed)
632 os.replace(tmp_path_obj, out_path)
633 except Exception:
634 tmp_path_obj.unlink(missing_ok=True)
635 raise
636
637 n_commits = len(bundle.get("commits", []))
638 n_blobs = len(bundle.get("blobs", []))
639 size_bytes = out_path.stat().st_size
640
641 if json_out:
642 print(json.dumps(_BundleCreateJson(
643 **make_envelope(elapsed),
644 file=str(out_path),
645 commits=n_commits,
646 blobs=n_blobs,
647 size_bytes=size_bytes,
648 branches=sorted(heads.keys()),
649 )))
650 else:
651 size_kb = size_bytes / 1024
652 print(
653 f"✅ Bundle: {sanitize_display(str(out_path))} "
654 f"({n_commits} commits, {n_blobs} blobs, {size_kb:.1f} KiB)"
655 )
656
657 def _verify_bundle_integrity(bundle: "MPack") -> list[str]:
658 """Run integrity checks on *bundle* and return a list of failure strings.
659
660 Returns an empty list when the bundle is clean. This is the shared core
661 used by both ``run_verify`` and the ``--verify`` flag on ``run_unbundle``.
662 """
663 failures: list[str] = []
664
665 bundle_obj_ids: set[str] = set()
666 for obj in bundle.get("blobs", []):
667 obj_id = obj.get("object_id", "")
668 raw = obj.get("content", b"")
669 if not obj_id:
670 failures.append("blobs list: entry has missing object_id")
671 continue
672 # NOTE: raw may be b"" for empty files — that is valid.
673 actual = blob_id(raw)
674 if actual != obj_id:
675 failures.append(f"blob {obj_id}: hash mismatch (corruption)")
676 else:
677 bundle_obj_ids.add(obj_id)
678
679 for snap_dict in bundle.get("snapshots", []):
680 snap_id = snap_dict.get("snapshot_id", "")
681 # Bundles use delta_upsert since feat(pack): delta-encode snapshots (1a6566e77).
682 # Only new objects introduced by this delta need to be present in the bundle;
683 # objects from parent snapshots live on the receiver's side.
684 delta_upsert: dict[str, str] = snap_dict.get("delta_upsert") or {}
685 for rel_path, obj_id in delta_upsert.items():
686 if obj_id not in bundle_obj_ids:
687 failures.append(
688 f"snapshot {snap_id}: "
689 f"missing object {obj_id} for {rel_path}"
690 )
691 return failures
692
693 def run_inspect(args: argparse.Namespace) -> None:
694 """Show the commit log and branch state recorded in a bundle file.
695
696 Reads all commits, branch heads, and agent provenance from a bundle file
697 without writing anything to the local repository. Does NOT require a Muse
698 repository — safe to run against any bundle file in isolation. Commits are
699 sorted newest-first by ``committed_at``.
700
701 Agent quickstart
702 ----------------
703 ::
704
705 muse bundle inspect repo.bundle --json
706 muse bundle inspect repo.bundle --json | jq '.commits[].message'
707
708 JSON fields
709 -----------
710 total_commits Number of commits in the bundle.
711 total_objects Number of content objects in the bundle.
712 branches Map of branch name → ``sha256:<64hex>`` commit ID.
713 commits List of commit objects, each with: ``commit_id``,
714 ``message`` (first line, max 72 chars), ``committed_at``
715 (ISO-8601), ``agent_id``, and ``branches`` (list of branch
716 names whose tip is this commit).
717
718 Exit codes
719 ----------
720 0 Bundle read successfully (``total_commits=0`` for an empty bundle).
721 1 Bundle file not found or not valid msgpack.
722 """
723 elapsed = start_timer()
724 file: str = args.file
725 json_out: bool = args.json_out
726
727 bundle = _load_bundle(pathlib.Path(file))
728
729 # Branch heads map: branch_name → commit_id (sha256: prefixed).
730 raw_heads: "BranchHeads" = bundle.get("branch_heads") or {}
731 # Invert for annotation: commit_id → [branch_name, ...]
732 cid_to_branches: dict[str, list[str]] = {}
733 for br, cid in raw_heads.items():
734 cid_to_branches.setdefault(cid, []).append(br)
735
736 # Build commit entries from the bundle's commits list.
737 commit_entries: list["_BundleInspectCommit"] = []
738 for c in bundle.get("commits", []):
739 cid = c.get("commit_id", "")
740 message_raw = c.get("message", "")
741 message = sanitize_display(message_raw.splitlines()[0][:72]) if message_raw else ""
742 committed_at = c.get("committed_at", "")
743 agent_id = sanitize_display(c.get("agent_id", "") or "")
744 branches = sorted(sanitize_display(b) for b in cid_to_branches.get(cid, []))
745 commit_entries.append(_BundleInspectCommit(
746 commit_id=cid,
747 message=message,
748 committed_at=committed_at,
749 agent_id=agent_id,
750 branches=branches,
751 ))
752
753 # Sort newest-first by committed_at (ISO-8601 sorts lexicographically).
754 commit_entries.sort(key=lambda e: e["committed_at"], reverse=True)
755
756 total_commits = len(commit_entries)
757 total_blobs = len(bundle.get("blobs", []))
758 branches_out = {
759 sanitize_display(k): sanitize_display(v)
760 for k, v in raw_heads.items()
761 }
762
763 if json_out:
764 print(json.dumps(_BundleInspectJson(
765 **make_envelope(elapsed),
766 total_commits=total_commits,
767 total_objects=total_blobs,
768 branches=branches_out,
769 commits=commit_entries,
770 )))
771 else:
772 print(f"Commits: {total_commits}")
773 print(f"Blobs: {total_blobs}")
774 if branches_out:
775 print(f"Branches: {', '.join(sorted(branches_out))}")
776 else:
777 print("Branches: (none)")
778 print()
779 for entry in commit_entries:
780 short = short_id(entry["commit_id"])
781 ts = entry["committed_at"][:19] if entry["committed_at"] else "unknown"
782 agent = f" [{entry['agent_id']}]" if entry["agent_id"] else ""
783 br_str = f" ({', '.join(entry['branches'])})" if entry["branches"] else ""
784 print(f"{short} {ts}{agent}{br_str}")
785 print(f" {entry['message']}")
786
787 def run_diff(args: argparse.Namespace) -> None:
788 """Show which commits in a bundle are not yet in the local repository.
789
790 Compares each bundle commit against the local store and reports which are
791 new. Also reports which branch refs would advance if the bundle were
792 applied. ``new_commits=0`` means the repo is already up-to-date. Requires
793 a Muse repository (unlike ``inspect``, ``verify``, ``list-heads``).
794
795 Agent quickstart
796 ----------------
797 ::
798
799 muse bundle diff repo.bundle --json
800 muse bundle diff repo.bundle --json | jq '.new_commits'
801
802 JSON fields
803 -----------
804 new_commits Number of commits in the bundle not yet in the local store.
805 known_commits Number of commits already present locally.
806 refs_to_advance Branch names whose local tip differs from the bundle tip.
807 commits List of new commit objects, each with: ``commit_id``,
808 ``message`` (first line, max 72 chars), ``committed_at``
809 (ISO-8601). Sorted newest-first.
810
811 Exit codes
812 ----------
813 0 Diff computed (even when ``new_commits == 0``).
814 1 Bundle file not found or not valid msgpack.
815 2 Not inside a Muse repository.
816 """
817 elapsed = start_timer()
818 file: str = args.file
819 json_out: bool = args.json_out
820
821 root = require_repo()
822 bundle = _load_bundle(pathlib.Path(file))
823
824 new_entries: list[_BundleDiffCommit] = []
825 known_count = 0
826
827 for c in bundle.get("commits", []):
828 cid = c.get("commit_id", "")
829 if not cid:
830 continue
831 local_rec = read_commit(root, cid)
832 if local_rec is None:
833 message_raw = c.get("message", "")
834 message = sanitize_display(message_raw.splitlines()[0][:72]) if message_raw else ""
835 new_entries.append(_BundleDiffCommit(
836 commit_id=cid,
837 message=message,
838 committed_at=c.get("committed_at", ""),
839 ))
840 else:
841 known_count += 1
842
843 # Sort new commits newest-first.
844 new_entries.sort(key=lambda e: e["committed_at"], reverse=True)
845
846 # Determine which branch refs would advance.
847 branch_heads: "BranchHeads" = bundle.get("branch_heads") or {}
848 refs_to_advance: list[str] = []
849 for br, bundle_cid in sorted(branch_heads.items()):
850 local_cid = get_head_commit_id(root, br)
851 if local_cid != bundle_cid:
852 refs_to_advance.append(sanitize_display(br))
853
854 if json_out:
855 print(json.dumps(_BundleDiffJson(
856 **make_envelope(elapsed),
857 new_commits=len(new_entries),
858 known_commits=known_count,
859 refs_to_advance=refs_to_advance,
860 commits=new_entries,
861 )))
862 else:
863 if not new_entries:
864 print(f"Already up-to-date. 0 new commits.")
865 else:
866 print(f"{len(new_entries)} new commit(s), {known_count} already known.")
867 if refs_to_advance:
868 print(f"Refs to advance: {', '.join(refs_to_advance)}")
869 for entry in new_entries:
870 short = short_id(entry["commit_id"])
871 ts = entry["committed_at"][:19] if entry["committed_at"] else "unknown"
872 print(f" {short} {ts} {entry['message']}")
873
874 def run_unbundle(args: argparse.Namespace) -> None:
875 """Apply a bundle to the local store and optionally advance branch refs.
876
877 Writes all commits, snapshots, and objects from a bundle into the local
878 repository, then advances local branch refs to match the bundle's recorded
879 heads. All writes are idempotent — objects already present are counted as
880 skipped, not errors. Use ``--verify`` to abort before any write if the
881 bundle fails an integrity check.
882
883 Agent quickstart
884 ----------------
885 ::
886
887 muse bundle unbundle repo.bundle --verify --json
888 muse bundle unbundle repo.bundle --no-update-refs --json
889
890 JSON fields
891 -----------
892 commits_written Number of commit records written to the local store.
893 snapshots_written Number of snapshot records written.
894 blobs_written Number of blobs written.
895 blobs_skipped Number of blobs already present (skipped).
896 refs_updated Branch names whose local tip was advanced.
897 verified ``true`` if ``--verify`` was set and integrity passed.
898
899 Exit codes
900 ----------
901 0 Bundle applied (all objects skipped = already up-to-date, also 0).
902 1 Bundle not found, not valid msgpack, or ``--verify`` integrity failure.
903 2 Not inside a Muse repository.
904 """
905 elapsed = start_timer()
906 file: str = args.file
907 update_refs: bool = args.update_refs
908 verify_first: bool = args.verify_first
909 json_out: bool = args.json_out
910
911 root = require_repo()
912 bundle = _load_bundle(pathlib.Path(file))
913
914 if verify_first:
915 failures = _verify_bundle_integrity(bundle)
916 if failures:
917 if json_out:
918 print(json.dumps({
919 "error": "integrity_failure",
920 "failures": [sanitize_display(f) for f in failures[:10]],
921 }))
922 else:
923 print(
924 f"❌ Bundle integrity check failed ({len(failures)} failure(s)) — "
925 "no changes written.",
926 file=sys.stderr,
927 )
928 for f in failures[:10]:
929 print(f" {sanitize_display(f)}", file=sys.stderr)
930 raise SystemExit(ExitCode.USER_ERROR)
931
932 result = apply_mpack(root, bundle)
933
934 updated: list[str] = []
935 if update_refs:
936 import re as _re
937 branch_heads: BranchHeads = bundle.get("branch_heads") or {}
938 for br, cid in branch_heads.items():
939 try:
940 validate_branch_name(br)
941 except ValueError:
942 logger.warning("⚠️ bundle: skipping invalid branch name %r", br)
943 continue
944 if not _re.fullmatch(r"sha256:[0-9a-f]{64}", cid):
945 logger.warning("⚠️ bundle: skipping invalid commit ID for %r", br)
946 continue
947 write_branch_ref(root, br, cid)
948 updated.append(br)
949
950 if json_out:
951 print(json.dumps(_BundleUnbundleJson(
952 **make_envelope(elapsed),
953 commits_written=result["commits_written"],
954 snapshots_written=result["snapshots_written"],
955 blobs_written=result["blobs_written"],
956 blobs_skipped=result["blobs_skipped"],
957 refs_updated=sorted(updated),
958 verified=verify_first,
959 )))
960 else:
961 print(
962 f"Unpacked {result['commits_written']} commit(s), "
963 f"{result['snapshots_written']} snapshot(s), "
964 f"{result['blobs_written']} blob(s) "
965 f"({result['blobs_skipped']} skipped)"
966 )
967 if updated:
968 print(
969 f"Updated refs: {', '.join(sanitize_display(b) for b in updated)}"
970 )
971 print("✅ Bundle applied.")
972
973 def run_verify(args: argparse.Namespace) -> None:
974 """Verify the integrity of a bundle file.
975
976 Checks every object's SHA-256 against its declared ``object_id`` (hash
977 mismatch means corruption) and confirms every snapshot's objects are present
978 in the bundle (missing object means truncation). Does NOT require a Muse
979 repository. At most 100 distinct failures are collected; beyond that a
980 trailing "... and N more" entry is appended.
981
982 Agent quickstart
983 ----------------
984 ::
985
986 muse bundle verify repo.bundle --json
987 muse bundle verify repo.bundle -q && muse bundle unbundle repo.bundle
988
989 JSON fields
990 -----------
991 blobs_checked Number of blobs whose hash was verified.
992 snapshots_checked Number of snapshots whose blob references were checked.
993 all_ok ``true`` when no integrity failures were found.
994 failures List of human-readable failure descriptions (capped at 100).
995
996 Exit codes
997 ----------
998 0 Bundle is clean.
999 1 Integrity failures found, or bundle file not found / not valid msgpack.
1000 """
1001 elapsed = start_timer()
1002 file: str = args.file
1003 quiet: bool = args.quiet
1004 json_out: bool = args.json_out
1005
1006 bundle = _load_bundle(pathlib.Path(file))
1007
1008 failures: list[str] = []
1009 blobs_checked = 0
1010 snapshots_checked = 0
1011 _total_failures = 0 # tracks raw count before the cap kicks in
1012
1013 def _add_failure(msg: str) -> None:
1014 nonlocal _total_failures
1015 _total_failures += 1
1016 if len(failures) < _MAX_VERIFY_FAILURES:
1017 failures.append(msg)
1018
1019 # Build set of blob IDs in the bundle and verify each hash.
1020 # Blob IDs use canonical "sha256:<64hex>" format; compare accordingly.
1021 bundle_obj_ids: set[str] = set()
1022 for obj in bundle.get("blobs", []):
1023 obj_id = obj["object_id"]
1024 raw = obj["content"]
1025 if not obj_id:
1026 _add_failure("blobs list: entry has missing object_id")
1027 continue
1028 # NOTE: raw may be b"" for empty files — that is valid.
1029 actual = blob_id(raw)
1030 if actual != obj_id:
1031 _add_failure(f"blob {obj_id}: hash mismatch (corruption)")
1032 else:
1033 bundle_obj_ids.add(obj_id)
1034 blobs_checked += 1
1035
1036 # Check snapshots reference objects present in the bundle.
1037 for snap_dict in bundle.get("snapshots", []):
1038 snap_id = snap_dict.get("snapshot_id", "")
1039 delta_upsert: dict[str, str] = snap_dict.get("delta_upsert") or {}
1040 snapshots_checked += 1
1041 for rel_path, obj_id in delta_upsert.items():
1042 if obj_id not in bundle_obj_ids:
1043 _add_failure(
1044 f"snapshot {snap_id}: "
1045 f"missing object {obj_id} for {rel_path}"
1046 )
1047
1048 overflow = _total_failures - len(failures)
1049 if overflow > 0:
1050 failures.append(f"... and {overflow} more failure(s) (cap: {_MAX_VERIFY_FAILURES})")
1051
1052 all_ok = _total_failures == 0
1053
1054 if quiet:
1055 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
1056
1057 if json_out:
1058 print(json.dumps(_BundleVerifyJson(
1059 **make_envelope(elapsed),
1060 blobs_checked=blobs_checked,
1061 snapshots_checked=snapshots_checked,
1062 all_ok=all_ok,
1063 failures=[sanitize_display(f) for f in failures],
1064 )))
1065 else:
1066 print(f"Blobs checked: {blobs_checked}")
1067 print(f"Snapshots checked: {snapshots_checked}")
1068 if all_ok:
1069 print("✅ Bundle is clean.")
1070 else:
1071 print(f"❌ {_total_failures} failure(s):")
1072 for f in failures:
1073 print(f" {sanitize_display(str(f))}")
1074
1075 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
1076
1077 def run_list_heads(args: argparse.Namespace) -> None:
1078 """List the branch heads recorded in a bundle file.
1079
1080 Reads the ``branch_heads`` map embedded in the bundle and prints it. Does
1081 NOT require a Muse repository — runs against any bundle file in isolation.
1082 Both branch names and commit IDs are sanitized before output to prevent
1083 ANSI injection from crafted bundles.
1084
1085 Agent quickstart
1086 ----------------
1087 ::
1088
1089 muse bundle list-heads repo.bundle --json
1090 muse bundle list-heads repo.bundle --json | jq 'keys'
1091
1092 JSON fields
1093 -----------
1094 <branch> Each key is a branch name; value is ``sha256:<64hex>`` commit ID.
1095 Top level is a flat map — no wrapper key.
1096
1097 Exit codes
1098 ----------
1099 0 Always (empty object when no heads are recorded in the bundle).
1100 1 Bundle file not found or not valid msgpack.
1101 """
1102 elapsed = start_timer()
1103 file: str = args.file
1104 json_out: bool = args.json_out
1105
1106 bundle = _load_bundle(pathlib.Path(file))
1107 heads: BranchHeads = bundle.get("branch_heads") or {}
1108
1109 if json_out:
1110 safe_heads = {
1111 sanitize_display(k): sanitize_display(v)
1112 for k, v in heads.items()
1113 }
1114 print(json.dumps({
1115 **make_envelope(elapsed),
1116 **_BundleListHeadsJson(heads=safe_heads),
1117 }))
1118 else:
1119 if not heads:
1120 print("No branch heads in bundle.")
1121 return
1122 for branch, cid in sorted(heads.items()):
1123 print(f"{sanitize_display(cid[:len('sha256:') + 12])} {sanitize_display(branch)}")
File History 1 commit
sha256:f8e686793bb93114c2923d0d294162d13b4e6f4d57ae0f6cbc1e0d493e80f965 fix: ls-remote signing identity uses resolved remote URL Sonnet 4.6 patch 12 days ago