gabriel / muse public
feat patch dev-safety feat/185-phase5-backup-tooling #1 / 1
AI Agent gabriel · 3 days ago · Sep 10, 2026 · Diff

feat(dev-safety): Phase 5 of #185 — belt-and-suspenders automated backups

Adds scripts/dev/backup.py + snapshot-store.sh + backup-bundle.sh + restore.sh: two independent backup mechanisms, deliberately not either/or.

Suspenders (fast, frequent, local): snapshot-store.sh <repo> — APFS copy-on-write snapshot of canonical's .muse/ only, rotated (default keep 20).

Belt (verified, portable): backup-bundle.sh <repo> — `muse bundle create` over every local branch, immediately `muse bundle verify`'d; a bundle that fails verification is deleted rather than left as a false sense of backup. Also rotated.

restore.sh <repo> --from-snapshot|--from-bundle <name> [--force] refuses to overwrite a canonical repo that currently passes `muse verify` unless --force is passed — CanonicalHealthyError, don't clobber a good copy by accident.

8 tests, written first, all building a REAL scratch muse repo under tmp_path (via the real `muse` binary) — canonical_root/backup_base always point at tmp_path, never ~/ecosystem/muse, matching every prior phase's discipline. Full round-trip for both mechanisms: corrupt an object, confirm `muse verify` catches it, restore, confirm verify passes and every branch's head_commit_id matches pre-corruption state exactly. Plus: rotation keeps only the newest N, restore refuses without force against a healthy repo but proceeds without force when already unhealthy, and bundle restore's diff against the source bundle reports zero new commits.

Real bug found and fixed while building this: `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 (corrupted) store silently leaves the corruption in place. restore_from_bundle now fully wipes and re-inits .muse/ before unbundling, matching restore_from_snapshot's full-replace semantics, rather than overlaying on top of possibly-bad existing state.

Manually verified against the real ~/ecosystem/muse: snapshot-store.sh succeeded and produced a real, working backup. backup-bundle.sh correctly *failed* — canonical has pre-existing, unrelated corruption (musehub staging #188, now reframed as a recovery investigation per gabriel's fidelity-first directive) that `muse bundle verify` correctly refused to certify. The bundle mechanism's fail-safe (delete rather than keep an unverified bundle) worked exactly as designed. Snapshot backup is unaffected and already has real coverage; bundle backup will start working once #188 is resolved.

sha256:a57e9ca1e385a1e7a0e3e28094bc35799950a3ed7b2421712d6fb7f0e523a4a0 sha
+56 symbols
5 changed · 1173 in snapshot files
sha256:d3703c67aea68db788d1ccafb5b9d852ea5524ab25eb6fc3f204b1b8a0439e7f snapshot
+56
symbols added
5
files changed
1173
files in snapshot
0
dead code introduced
Semantic Changes 56 symbols
~ scripts/dev/backup.py .py 27 symbols added
+ CanonicalHealthyError class class CanonicalHealthyError L34–35
+ DEFAULT_BACKUP_BASE variable variable DEFAULT_BACKUP_BASE L29–29
+ DEFAULT_BUNDLE_KEEP variable variable DEFAULT_BUNDLE_KEEP L31–31
+ DEFAULT_CANONICAL_ROOTS variable variable DEFAULT_CANONICAL_ROOTS L25–28
+ DEFAULT_SNAPSHOT_KEEP variable variable DEFAULT_SNAPSHOT_KEEP L30–30
+ _bundle_dir function function _bundle_dir L142–143
+ _canonical_is_healthy function function _canonical_is_healthy L50–59
+ _guard_restore function function _guard_restore L62–69
+ _prune function function _prune L72–78
+ _resolve_canonical function function _resolve_canonical L38–43
+ _snapshot_dir function function _snapshot_dir L83–84
+ _timestamp function function _timestamp L46–47
+ create_bundle_backup function function create_bundle_backup L146–178
+ create_snapshot function function create_snapshot L87–108
+ Path import import Path L23–23
+ annotations import import annotations L16–16
+ datetime import import datetime L22–22
+ json import import json L18–18
+ shutil import import shutil L19–19
+ subprocess import import subprocess L20–20
+ sys import import sys L21–21
+ timezone import import timezone L22–22
+ list_bundles function function list_bundles L181–189
+ list_snapshots function function list_snapshots L111–116
+ main function function main L220–267
+ restore_from_bundle function function restore_from_bundle L192–217
+ restore_from_snapshot function function restore_from_snapshot L119–137
~ tests/test_backup.py .py 29 symbols added
+ MUSE variable variable MUSE L28–28
+ TestBundleBackupRestore class class TestBundleBackupRestore L131–187
+ test_bundle_diff_reports_zero_new_commits_after_restore method method test_bundle_diff_reports_zero_new_commits_after_restore L149–162
+ test_bundle_is_verified_at_creation_time method method test_bundle_is_verified_at_creation_time L164–173
+ test_full_round_trip_via_bundle method method test_full_round_trip_via_bundle L132–147
+ test_rotation_keeps_only_newest_n method method test_rotation_keeps_only_newest_n L175–187
+ TestSnapshotBackupRestore class class TestSnapshotBackupRestore L73–128
+ test_full_round_trip_via_snapshot method method test_full_round_trip_via_snapshot L74–89
+ test_refuses_to_restore_over_healthy_canonical_without_force method method test_refuses_to_restore_over_healthy_canonical_without_force L105–113
+ test_restores_without_force_when_canonical_already_unhealthy method method test_restores_without_force_when_canonical_already_unhealthy L115–128
+ test_rotation_keeps_only_newest_n method method test_rotation_keeps_only_newest_n L91–103
+ _corrupt_an_object function function _corrupt_an_object L56–64
+ _make_real_repo function function _make_real_repo L35–41
+ _require_muse function function _require_muse L68–70
+ _rev_parse function function _rev_parse L44–47
+ _run function function _run L31–32
+ _verify_all_ok function function _verify_all_ok L50–53
+ CanonicalHealthyError import import CanonicalHealthyError L18–18
+ Path import import Path L13–13
+ create_bundle_backup import import create_bundle_backup L18–18
+ create_snapshot import import create_snapshot L18–18
+ list_bundles import import list_bundles L18–18
+ list_snapshots import import list_snapshots L18–18
+ pytest import import pytest L15–15
+ restore_from_bundle import import restore_from_bundle L18–18
+ restore_from_snapshot import import restore_from_snapshot L18–18
+ shutil import import shutil L10–10
+ subprocess import import subprocess L11–11
+ sys import import sys L12–12
Files Changed
+5
1173 in snapshot
← Older Oldest on feat/185-phase5-backup-tooling
All commits
Newer → Latest on feat/185-phase5-backup-tooling

0 comments

No comments yet. Be the first to start the discussion.

To add a comment, use the Muse CLI: muse hub commit comment sha256:a57e9ca1e385a1e7a0e3e28094bc35799950a3ed7b2421712d6fb7f0e523a4a0 --body "your comment"