#!/usr/bin/env python3 """Belt-and-suspenders backups for canonical muse/musehub repos (#185 Phase 5). Two independent mechanisms, on purpose — not either/or: - **Suspenders** (fast, frequent, local): APFS copy-on-write snapshots of just ``.muse/`` (not the working tree). Near-instant, cheap enough to take before any risky operation. - **Belt** (verified, portable, survives an APFS-level disaster): a `muse bundle` archive of every branch, immediately verified after creation. Restore refuses to overwrite a canonical repo that currently passes `muse verify` unless ``force=True`` — don't clobber a good copy by accident. """ from __future__ import annotations import json import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path DEFAULT_CANONICAL_ROOTS = { "muse": Path.home() / "ecosystem" / "muse", "musehub": Path.home() / "ecosystem" / "musehub", } DEFAULT_BACKUP_BASE = Path.home() / "dev" / "backups" DEFAULT_SNAPSHOT_KEEP = 20 DEFAULT_BUNDLE_KEEP = 20 class CanonicalHealthyError(RuntimeError): """Raised when a restore is refused because canonical already passes `muse verify`.""" def _resolve_canonical(repo: str, canonical_root: Path | None) -> Path: if canonical_root is not None: return canonical_root if repo not in DEFAULT_CANONICAL_ROOTS: raise ValueError(f"Unknown repo {repo!r} — no default canonical root registered for it.") return DEFAULT_CANONICAL_ROOTS[repo] def _timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f") def _canonical_is_healthy(canonical_root: Path) -> bool: proc = subprocess.run( ["muse", "verify", "--json"], cwd=canonical_root, capture_output=True, text=True, ) if proc.returncode not in (0, 1): raise RuntimeError(f"`muse verify` failed unexpectedly: {proc.stderr.strip()}") try: return bool(json.loads(proc.stdout).get("all_ok", False)) except json.JSONDecodeError: return False def _guard_restore(canonical_root: Path, force: bool) -> None: if force: return if _canonical_is_healthy(canonical_root): raise CanonicalHealthyError( f"{canonical_root} currently passes `muse verify` — refusing to restore over " f"a healthy repo. Pass force=True if you're certain." ) def _prune(dir_path: Path, *, keep: int, is_relevant) -> None: entries = sorted((p for p in dir_path.iterdir() if is_relevant(p)), key=lambda p: p.name) for stale in entries[:-keep] if keep > 0 else entries: if stale.is_dir(): shutil.rmtree(stale) else: stale.unlink() # ── Snapshots ("suspenders") ────────────────────────────────────────────── def _snapshot_dir(repo: str, backup_base: Path) -> Path: return backup_base / f"{repo}-snapshots" def create_snapshot( repo: str, *, canonical_root: Path | None = None, backup_base: Path | None = None, keep: int = DEFAULT_SNAPSHOT_KEEP, ) -> Path: """APFS copy-on-write snapshot of canonical's ``.muse/`` only.""" root = _resolve_canonical(repo, canonical_root) base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE snap_root = _snapshot_dir(repo, base) snap_root.mkdir(parents=True, exist_ok=True) dest = snap_root / _timestamp() if dest.exists(): raise RuntimeError(f"Snapshot destination already exists (clock collision?): {dest}") dest.mkdir(parents=True) subprocess.run( ["cp", "-c", "-R", str(root / ".muse"), str(dest / ".muse")], check=True, capture_output=True, text=True, ) _prune(snap_root, keep=keep, is_relevant=lambda p: p.is_dir()) return dest def list_snapshots(repo: str, *, backup_base: Path | None = None) -> list[Path]: base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE snap_root = _snapshot_dir(repo, base) if not snap_root.exists(): return [] return sorted((p for p in snap_root.iterdir() if p.is_dir()), key=lambda p: p.name) def restore_from_snapshot( repo: str, snapshot_name: str, *, canonical_root: Path | None = None, backup_base: Path | None = None, force: bool = False, ) -> None: root = _resolve_canonical(repo, canonical_root) base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE snapshot_path = _snapshot_dir(repo, base) / snapshot_name / ".muse" if not snapshot_path.exists(): raise FileNotFoundError(f"No such snapshot: {snapshot_path}") _guard_restore(root, force) live_muse = root / ".muse" if live_muse.exists(): shutil.rmtree(live_muse) subprocess.run( ["cp", "-c", "-R", str(snapshot_path), str(live_muse)], check=True, capture_output=True, text=True, ) # ── Bundles ("belt") ────────────────────────────────────────────────────── def _bundle_dir(repo: str, backup_base: Path) -> Path: return backup_base / f"{repo}-bundles" def create_bundle_backup( repo: str, *, canonical_root: Path | None = None, backup_base: Path | None = None, keep: int = DEFAULT_BUNDLE_KEEP, ) -> Path: """`muse bundle create` over every local branch, immediately verified.""" root = _resolve_canonical(repo, canonical_root) base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE bundle_root = _bundle_dir(repo, base) bundle_root.mkdir(parents=True, exist_ok=True) branches_proc = subprocess.run( ["muse", "branch", "--json"], cwd=root, capture_output=True, text=True, check=True, ) branch_names = [b["name"] for b in json.loads(branches_proc.stdout)] if not branch_names: raise RuntimeError(f"No branches found in {root} — nothing to bundle.") dest = bundle_root / f"{_timestamp()}.muse" subprocess.run( ["muse", "bundle", "create", str(dest), *branch_names, "--json"], cwd=root, check=True, capture_output=True, text=True, ) verify_proc = subprocess.run( ["muse", "bundle", "verify", str(dest), "--json"], cwd=root, capture_output=True, text=True, ) if not json.loads(verify_proc.stdout).get("all_ok", False): dest.unlink(missing_ok=True) raise RuntimeError(f"Freshly created bundle failed verification: {verify_proc.stdout}") _prune(bundle_root, keep=keep, is_relevant=lambda p: p.is_file() and p.suffix == ".muse") return dest def list_bundles(repo: str, *, backup_base: Path | None = None) -> list[Path]: base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE bundle_root = _bundle_dir(repo, base) if not bundle_root.exists(): return [] return sorted( (p for p in bundle_root.iterdir() if p.is_file() and p.suffix == ".muse"), key=lambda p: p.name, ) def restore_from_bundle( repo: str, bundle_name: str, *, canonical_root: Path | None = None, backup_base: Path | None = None, force: bool = False, ) -> None: root = _resolve_canonical(repo, canonical_root) base = backup_base if backup_base is not None else DEFAULT_BACKUP_BASE bundle_path = _bundle_dir(repo, base) / bundle_name if not bundle_path.exists(): raise FileNotFoundError(f"No such bundle: {bundle_path}") _guard_restore(root, force) # `muse bundle unbundle` treats an object as "already present" purely by # path existing on disk — it never re-validates content, so unbundling # on top of an existing (possibly corrupted) store can silently leave # corruption in place. A restore must fully replace, the same way # restore_from_snapshot does, not overlay. live_muse = root / ".muse" if live_muse.exists(): shutil.rmtree(live_muse) subprocess.run(["muse", "init", "--json"], cwd=root, check=True, capture_output=True, text=True) subprocess.run( ["muse", "bundle", "unbundle", str(bundle_path), "--verify", "--json"], cwd=root, check=True, capture_output=True, text=True, ) def main(argv: list[str] | None = None) -> int: import argparse parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="action", required=True) for name, fn in (("snapshot", create_snapshot), ("bundle", create_bundle_backup)): p = subparsers.add_parser(name, help=f"Create a {name} backup.") p.add_argument("repo") p.add_argument("--keep", type=int, default=DEFAULT_SNAPSHOT_KEEP if name == "snapshot" else DEFAULT_BUNDLE_KEEP) p.add_argument("--backup-base", default=None, help=argparse.SUPPRESS) p.add_argument("--json", action="store_true") p.set_defaults(_create=fn) restore_p = subparsers.add_parser("restore", help="Restore canonical from a snapshot or bundle.") restore_p.add_argument("repo") restore_p.add_argument("--from-snapshot", default=None) restore_p.add_argument("--from-bundle", default=None) restore_p.add_argument("--force", action="store_true") restore_p.add_argument("--backup-base", default=None, help=argparse.SUPPRESS) restore_p.add_argument("--json", action="store_true") args = parser.parse_args(argv) backup_base = Path(args.backup_base) if args.backup_base else None try: if args.action in ("snapshot", "bundle"): path = args._create(args.repo, backup_base=backup_base, keep=args.keep) print(json.dumps({"repo": args.repo, "path": str(path)}) if args.json else f"✅ Created {args.action} backup: {path}") return 0 if args.action == "restore": if bool(args.from_snapshot) == bool(args.from_bundle): print("❌ Exactly one of --from-snapshot or --from-bundle is required.", file=sys.stderr) return 1 if args.from_snapshot: restore_from_snapshot(args.repo, args.from_snapshot, backup_base=backup_base, force=args.force) else: restore_from_bundle(args.repo, args.from_bundle, backup_base=backup_base, force=args.force) print(json.dumps({"repo": args.repo, "status": "restored"}) if args.json else f"✅ Restored {args.repo}.") return 0 except (ValueError, FileNotFoundError, RuntimeError, CanonicalHealthyError) as e: print(f"❌ {e}", file=sys.stderr) return 1 return 1 if __name__ == "__main__": sys.exit(main())