"""``muse snapshot`` — explicit snapshot management. A snapshot is Muse's fundamental unit of state: a content-addressed, immutable record mapping workspace-relative paths to their SHA-256 object IDs. Every commit points to exactly one snapshot. ``muse snapshot`` makes snapshots a first-class operation — you can capture, list, inspect, and export them independently of the commit workflow. This is especially useful for agents that want to checkpoint mid-work without creating a formal commit. Subcommands:: muse snapshot create [-m ] [--json] Capture current working-tree state. Notes are persisted in the record. muse snapshot list [--limit N] [--json] List all stored snapshots, newest first. muse snapshot show [--text] Print a snapshot manifest (default: JSON; --text for human output). muse snapshot export [-f tar.gz|zip] [-o file] [--json] Export tracked files to a portable archive. --json emits a JSON summary of the export alongside creating the archive. Security model:: Symlinks inside ``.muse/snapshots/`` are silently skipped during listing and prefix-scanning. A crafted symlink pointing outside the repository boundary could otherwise expose external file contents. The zip-slip guard (``_safe_arcname``) rejects any manifest entry whose normalised path escapes the archive root. Both ``..`` segments and absolute paths are rejected before any file is opened. JSON output schemas:: snapshot create --json: {"repo_id": str, "snapshot_id": str, "file_count": int, "note": str, "created_at": str} snapshot list --json: [{"snapshot_id": str, "file_count": int, "note": str, "created_at": str}, ...] snapshot show --json: {"snapshot_id": str, "created_at": str, "file_count": int, "note": str, "manifest": {path: object_id, ...}} snapshot export --json: {"snapshot_id": str, "output": str, "format": str, "file_count": int, "size_bytes": int} Exit codes:: 0 — success 1 — snapshot not found, bad arguments 3 — I/O error """ from __future__ import annotations import argparse import json import logging import pathlib import tarfile import zipfile import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.object_store import object_path, write_object_from_path from muse.core.repo import read_repo_id, require_repo from muse.core.snapshot import compute_snapshot_id, directories_from_manifest from muse.core._types import Manifest from muse.core.store import SnapshotRecord, read_snapshot, write_snapshot from muse.core.validation import clamp_int, sanitize_display from muse.plugins.registry import resolve_plugin logger = logging.getLogger(__name__) _HEX_CHARS = frozenset("0123456789abcdef") # --------------------------------------------------------------------------- # JSON wire-format TypedDicts # --------------------------------------------------------------------------- class _SnapshotCreateJson(TypedDict): """JSON output for ``muse snapshot create --json``.""" repo_id: str snapshot_id: str file_count: int note: str created_at: str class _SnapshotListItemJson(TypedDict): """One entry in the ``muse snapshot list --json`` array.""" snapshot_id: str file_count: int note: str created_at: str class _SnapshotShowJson(TypedDict): """JSON output for ``muse snapshot show --json``.""" snapshot_id: str created_at: str file_count: int note: str manifest: Manifest class _SnapshotExportJson(TypedDict): """JSON output for ``muse snapshot export --json``.""" snapshot_id: str output: str format: str file_count: int size_bytes: int # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _safe_arcname(prefix: str, rel_path: str) -> str | None: """Build an archive entry name that cannot escape the archive root (zip-slip guard). Returns ``None`` when *rel_path* resolves outside the intended prefix, in which case the caller should skip that entry. ``prefix`` must not contain ``..`` segments; the caller is responsible for validating it. """ clean_prefix = prefix.rstrip("/").strip() if prefix else "" if ".." in clean_prefix.split("/"): return None resolved = pathlib.PurePosixPath(rel_path) if resolved.is_absolute(): return None parts = resolved.parts if ".." in parts: return None safe_rel = str(resolved) return (clean_prefix + "/" + safe_rel) if clean_prefix else safe_rel def _validate_snapshot_id_prefix(snapshot_id: str) -> str: """Return a glob-safe prefix from *snapshot_id* (hex chars only, max 64).""" return "".join(c for c in snapshot_id[:64] if c in _HEX_CHARS) def _list_all_snapshots(root: pathlib.Path) -> list[SnapshotRecord]: """Return all stored snapshots sorted newest-first. Security: symlinks inside ``.muse/snapshots/`` are silently skipped. """ snaps_dir = root / ".muse" / "snapshots" if not snaps_dir.exists(): return [] results: list[SnapshotRecord] = [] for path in snaps_dir.glob("*.msgpack"): if path.is_symlink(): logger.warning("⚠️ snapshot: skipping symlink in snapshots dir: %s", path) continue record = read_snapshot(root, path.stem) if record is not None: results.append(record) return sorted(results, key=lambda s: s.created_at, reverse=True) def _resolve_snapshot(root: pathlib.Path, snapshot_id: str) -> SnapshotRecord | None: """Resolve *snapshot_id* (full or prefix) to a :class:`SnapshotRecord`. Tries the full ID first. Falls back to a prefix scan using a hex-sanitised glob pattern. Symlinks are skipped during the prefix scan. Returns ``None`` when no matching snapshot is found. """ snap = read_snapshot(root, snapshot_id) if snap is not None: return snap snaps_dir = root / ".muse" / "snapshots" safe_prefix = _validate_snapshot_id_prefix(snapshot_id) for p in snaps_dir.glob(f"{safe_prefix}*.msgpack"): if p.is_symlink(): logger.warning("⚠️ snapshot: skipping symlink during prefix scan: %s", p) continue snap = read_snapshot(root, p.stem) if snap is not None: return snap return None def _build_tar( root: pathlib.Path, manifest: Manifest, output_path: pathlib.Path, prefix: str, ) -> int: """Write a tar.gz archive from *manifest*; return the number of files written.""" count = 0 with tarfile.open(output_path, "w:gz") as tar: for rel_path, object_id in sorted(manifest.items()): arcname = _safe_arcname(prefix, rel_path) if arcname is None: logger.warning("⚠️ Skipping unsafe path in manifest: %s", rel_path) continue obj = object_path(root, object_id) if not obj.exists(): logger.warning( "⚠️ Missing object %s for %s — skipping", object_id[:12], rel_path ) continue tar.add(str(obj), arcname=arcname, recursive=False) count += 1 return count def _build_zip( root: pathlib.Path, manifest: Manifest, output_path: pathlib.Path, prefix: str, ) -> int: """Write a zip archive from *manifest*; return the number of files written.""" count = 0 with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: for rel_path, object_id in sorted(manifest.items()): arcname = _safe_arcname(prefix, rel_path) if arcname is None: logger.warning("⚠️ Skipping unsafe path in manifest: %s", rel_path) continue obj = object_path(root, object_id) if not obj.exists(): logger.warning( "⚠️ Missing object %s for %s — skipping", object_id[:12], rel_path ) continue zf.write(str(obj), arcname=arcname) count += 1 return count # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the snapshot subcommand.""" parser = subparsers.add_parser( "snapshot", help="Explicit snapshot management.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = True # --- create --- create_p = subs.add_parser( "create", help="Capture the current working tree as a snapshot without committing.", description=( "Hash every tracked file, store content in the object store, and\n" "write a SnapshotRecord to ``.muse/snapshots/``. No commit is\n" "created — the snapshot is a standalone checkpoint usable for\n" "inspection, export, or rollback independently of the commit graph.\n\n" "The optional ``-m`` / ``--note`` label is persisted inside the\n" "snapshot record and appears in ``muse snapshot list`` and\n" "``muse snapshot show``.\n\n" "Agent quickstart\n" "----------------\n" " muse snapshot create --json\n" " muse snapshot create -m 'before refactor' --json\n" " SNAP=$(muse snapshot create --json | jq -r .snapshot_id)\n\n" "JSON output schema\n" "------------------\n" ' {"repo_id": "", "snapshot_id": "",\n' ' "file_count": , "note": "",\n' ' "created_at": ""}\n\n' "Exit codes\n" "----------\n" " 0 — snapshot created\n" " 2 — not inside a Muse repository\n" " 3 — I/O error writing to the object store\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) create_p.add_argument( "-m", "--note", default="", metavar="NOTE", help="Optional note stored with the snapshot.", ) create_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit a machine-readable JSON summary.", ) create_p.set_defaults(func=run_snapshot_create) # --- export --- export_p = subs.add_parser( "export", help="Export a snapshot as a portable archive.", description=( "Write tracked snapshot files to a tar.gz or zip archive. No\n" "``.muse/`` metadata is included. A zip-slip guard rejects any\n" "manifest entry whose path escapes the archive root (absolute paths\n" "and ``..`` segments are silently skipped with a warning).\n\n" "If ``--output`` is omitted the archive is written to\n" "``.`` in the current directory. Use ``--prefix``\n" "to nest all files under a directory inside the archive.\n\n" "Agent quickstart\n" "----------------\n" " muse snapshot export --json\n" " muse snapshot export -f zip -o release.zip --json\n" " muse snapshot export --prefix myproject/ --json\n\n" "JSON output schema\n" "------------------\n" ' {"snapshot_id": "", "output": "",\n' ' "format": "tar.gz"|"zip", "file_count": ,\n' ' "size_bytes": }\n\n' "Exit codes\n" "----------\n" " 0 — archive written successfully\n" " 1 — snapshot ID not found\n" " 2 — not inside a Muse repository\n" " 3 — I/O error writing the archive\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) export_p.add_argument("snapshot_id", metavar="ID", help="Snapshot ID (full or prefix).") export_p.add_argument( "--format", "-f", dest="fmt", default="tar.gz", choices=["tar.gz", "zip"], help="Archive format: tar.gz (default) or zip.", ) export_p.add_argument( "--output", "-o", default=None, metavar="FILE", help="Output file path." ) export_p.add_argument( "--prefix", default="", metavar="PREFIX", help="Path prefix inside the archive.", ) export_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit a JSON summary (output path, file count, size) after creating the archive.", ) export_p.set_defaults(func=run_snapshot_export) # --- list --- list_p = subs.add_parser( "list", help="List all stored snapshots, newest first.", description=( "Scan ``.muse/snapshots/`` and print all snapshot records sorted\n" "newest-first. Symlinks in the snapshot directory are silently\n" "skipped. Use ``--limit`` / ``-n`` to cap results (default 20).\n\n" "Agent quickstart\n" "----------------\n" " muse snapshot list --json\n" " muse snapshot list -n 5 --json\n" " muse snapshot list -n 100 --json\n\n" "JSON output schema\n" "------------------\n" " Array of snapshot items, newest first:\n" ' [{"snapshot_id": "", "file_count": ,\n' ' "note": "", "created_at": ""}, ...]\n\n' "Exit codes\n" "----------\n" " 0 — list returned (may be empty)\n" " 1 — --limit value out of range\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) list_p.add_argument( "--limit", "-n", type=int, default=20, metavar="N", help="Maximum snapshots to show (default: 20, max: 100000).", ) list_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit a machine-readable JSON array.", ) list_p.set_defaults(func=run_snapshot_list) # --- show --- show_p = subs.add_parser( "show", help="Print the full manifest of a snapshot.", description=( "Display every file in a snapshot. Output is JSON by default —\n" "the most useful format for agents. Pass ``--text`` for a\n" "human-readable path listing.\n\n" "Accepts a full 64-character snapshot ID or any unambiguous\n" "prefix. The prefix scan skips symlinks in ``.muse/snapshots/``\n" "(security guard against crafted symlinks).\n\n" "Agent quickstart\n" "----------------\n" " muse snapshot show \n" " muse snapshot show \n" " SNAP=$(muse snapshot create --json | jq -r .snapshot_id)\n" " muse snapshot show \"$SNAP\"\n\n" "JSON output schema (default — no flag needed)\n" "----------------------------------------------\n" ' {"snapshot_id": "", "created_at": "",\n' ' "file_count": , "note": "",\n' ' "manifest": {"": "", ...}}\n\n' "Exit codes\n" "----------\n" " 0 — snapshot found and printed\n" " 1 — snapshot ID not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) show_p.add_argument("snapshot_id", metavar="ID", help="Snapshot ID (full or prefix).") show_p.add_argument( "--text", action="store_true", dest="text_out", help="Emit human-readable text instead of JSON (default: JSON).", ) show_p.set_defaults(func=run_snapshot_show) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_snapshot_create(args: argparse.Namespace) -> None: """Capture the current working tree as a snapshot without committing. Hashes every tracked file, stores their content in the object store, and writes a :class:`SnapshotRecord` to ``.muse/snapshots/``. No commit is created — the snapshot is a standalone checkpoint that can be listed, inspected, and exported independently. The optional ``--note`` / ``-m`` label is persisted inside the snapshot record and survives across ``muse snapshot list`` and ``muse snapshot show``. JSON output fields (``--json`` / ``-j``) ----------------------------------------- ``repo_id`` Repository identifier from ``repo.json``. ``snapshot_id`` 64-character hex content-hash of the captured manifest. ``file_count`` Number of files captured. ``note`` The ``--note`` value (empty string if not supplied). ``created_at`` ISO-8601 timestamp of when the snapshot was created. Exit codes ---------- 0 Snapshot created successfully. 2 Not inside a Muse repository. 3 I/O error writing to the object store or snapshot index. Examples:: muse snapshot create muse snapshot create -m "before refactor" SNAP=$(muse snapshot create --json | jq -r .snapshot_id) muse snapshot export "$SNAP" --output before.tar.gz """ note: str = args.note json_out: bool = args.json_out root = require_repo() plugin = resolve_plugin(root) snap_result = plugin.snapshot(root) manifest: Manifest = snap_result["files"] snap_dirs = list(snap_result.get("directories") or []) for rel_path, object_id in manifest.items(): src = root / rel_path if src.exists(): try: write_object_from_path(root, object_id, src) except (ValueError, OSError) as exc: logger.warning("⚠️ Could not store %s: %s", rel_path, exc) if not snap_dirs: snap_dirs = directories_from_manifest(manifest) snapshot_id = compute_snapshot_id(manifest, snap_dirs) record = SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest, directories=snap_dirs, note=note) write_snapshot(root, record) if json_out: repo_id = read_repo_id(root) or "" payload: _SnapshotCreateJson = { "repo_id": repo_id, "snapshot_id": snapshot_id, "file_count": len(manifest), "note": note, "created_at": record.created_at.isoformat(), } print(json.dumps(payload)) else: print(f"Snapshot {snapshot_id[:12]} ({len(manifest)} file(s))") if note: print(f"Note: {sanitize_display(note)}") def run_snapshot_list(args: argparse.Namespace) -> None: """List all stored snapshots, newest first. Scans ``.muse/snapshots/`` and returns records sorted by creation timestamp descending. Symlinks inside the snapshots directory are silently skipped (security guard against crafted symlinks). Use ``--limit`` / ``-n`` to cap the number of results (default 20, max 100 000). ``--limit 0`` is rejected with a user-error. JSON output fields per item (``--json`` / ``-j``) --------------------------------------------------- ``snapshot_id`` 64-character hex content-hash of the snapshot manifest. ``file_count`` Number of files in the snapshot. ``note`` The note label stored at create time (empty string if none). ``created_at`` ISO-8601 timestamp of when the snapshot was created. Exit codes ---------- 0 List returned (may be empty array / "No snapshots found."). 1 ``--limit`` value is out of range (must be 1–100 000). 2 Not inside a Muse repository. Examples:: muse snapshot list muse snapshot list -n 5 muse snapshot list --json muse snapshot list -n 50 --json """ try: limit: int = clamp_int(args.limit, 1, 100000, "limit") except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) json_out: bool = args.json_out root = require_repo() snapshots = _list_all_snapshots(root) if limit: snapshots = snapshots[:limit] if not snapshots: if json_out: print("[]") else: print("No snapshots found.") return if json_out: items: list[_SnapshotListItemJson] = [ { "snapshot_id": s.snapshot_id, "file_count": len(s.manifest), "note": s.note, "created_at": s.created_at.isoformat(), } for s in snapshots ] print(json.dumps(items)) else: for s in snapshots: when = s.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") note_suffix = f" {sanitize_display(s.note)}" if s.note else "" print(f"{s.snapshot_id[:12]} {when} {len(s.manifest)} file(s){note_suffix}") def run_snapshot_show(args: argparse.Namespace) -> None: """Print the full manifest of a snapshot. Defaults to JSON output — the most useful form for agents. Pass ``--text`` for a human-readable path listing. Accepts a full 64-character snapshot ID or any unambiguous prefix. The prefix scan skips symlinks in ``.muse/snapshots/`` (security guard). JSON output fields (default, no flag needed) -------------------------------------------- ``snapshot_id`` 64-character hex content-hash of the manifest. ``created_at`` ISO-8601 timestamp of snapshot creation. ``file_count`` Number of files in the manifest. ``note`` Label stored at create time (empty string if none). ``manifest`` Mapping of workspace-relative path → SHA-256 object ID, sorted alphabetically by path. Exit codes ---------- 0 Snapshot found and printed. 1 Snapshot ID not found (full ID or prefix matched nothing). 2 Not inside a Muse repository. Examples:: muse snapshot show abc123 muse snapshot show abc123 --text """ snapshot_id: str = args.snapshot_id text_out: bool = args.text_out root = require_repo() snap = _resolve_snapshot(root, snapshot_id) if snap is None: print( f"❌ Snapshot '{sanitize_display(snapshot_id)}' not found.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if text_out: print(f"snapshot_id: {snap.snapshot_id}") print(f"created_at: {snap.created_at.isoformat()}") if snap.note: print(f"note: {sanitize_display(snap.note)}") print(f"files ({len(snap.manifest)}):") for rel_path, obj_id in sorted(snap.manifest.items()): print(f" {obj_id[:12]} {sanitize_display(rel_path)}") else: payload: _SnapshotShowJson = { "snapshot_id": snap.snapshot_id, "created_at": snap.created_at.isoformat(), "file_count": len(snap.manifest), "note": snap.note, "manifest": dict(sorted(snap.manifest.items())), } print(json.dumps(payload)) def run_snapshot_export(args: argparse.Namespace) -> None: """Export a snapshot as a portable tar.gz or zip archive. The archive contains only tracked files — no ``.muse/`` metadata. A zip-slip guard (``_safe_arcname``) rejects any manifest entry whose normalised path escapes the archive root; both absolute paths and ``..`` segments are rejected before any file is opened. Missing objects (not yet written to the object store) are silently skipped with a warning log — the archive is still created with the remaining files. Use ``--json`` / ``-j`` to receive a machine-readable summary alongside the archive. This is the recommended form for agent pipelines that need the exact output path and byte count without parsing text output. JSON output fields (``--json`` / ``-j``) ----------------------------------------- ``snapshot_id`` Full 64-character hex ID of the exported snapshot. ``output`` Absolute or relative path of the archive file that was written. ``format`` ``"tar.gz"`` or ``"zip"``. ``file_count`` Number of files written into the archive (unsafe/missing entries excluded). ``size_bytes`` Size of the written archive in bytes. Exit codes ---------- 0 Archive written successfully. 1 Snapshot ID not found. 2 Not inside a Muse repository. 3 I/O error writing the archive. Examples:: muse snapshot export abc123 muse snapshot export abc123 -f zip -o release.zip muse snapshot export abc123 --prefix myproject/ muse snapshot export abc123 --json """ snapshot_id: str = args.snapshot_id fmt: str = args.fmt output: str | None = args.output prefix: str = args.prefix json_out: bool = args.json_out root = require_repo() snap = _resolve_snapshot(root, snapshot_id) if snap is None: print( f"❌ Snapshot '{sanitize_display(snapshot_id)}' not found.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) short = snap.snapshot_id[:12] out_name = output or f"{short}.{fmt}" out_path = pathlib.Path(out_name) if fmt == "tar.gz": count = _build_tar(root, snap.manifest, out_path, prefix) else: count = _build_zip(root, snap.manifest, out_path, prefix) size_bytes = out_path.stat().st_size if out_path.exists() else 0 if json_out: export_payload: _SnapshotExportJson = { "snapshot_id": snap.snapshot_id, "output": str(out_path), "format": fmt, "file_count": count, "size_bytes": size_bytes, } print(json.dumps(export_payload)) else: size_kb = size_bytes / 1024 print( f"✅ Archive: {sanitize_display(str(out_path))} ({count} file(s), {size_kb:.1f} KiB)\n" f" Snapshot: {short}" )