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