← back to Nas Setup
scripts/backup-repos-to-henry.sh
84 lines
#!/usr/bin/env bash
# Daily off-machine backup of ALL ~/Projects git repos → Henry (NAS), as verifiable
# git bundles. Most repos are LOCAL-ONLY (Steve's "no remotes by default" rule) so this
# is their ONLY off-machine copy — if Mac2's disk dies, this is the recovery path.
#
# DESIGN: `git bundle create --all` captures every ref + full history in ONE file,
# restorable via `git clone <bundle> <dir>`. Overwrite-in-place = always-current, FIXED
# footprint (no daily accumulation that could re-fill anything). tmp+mv+verify = never a
# half-written corrupt bundle. HARD mount-guard = aborts rather than ever writing to Mac2
# (writing backups onto the box you're backing up is what filled the disk in the first place).
set -uo pipefail
# --- TESTABILITY SEAM (TK-11648) --------------------------------------------------------
# A negative test must exercise THIS script, not a copy of its guard: a guard-copy test
# stays green if the shipped guard is later reverted (CLAUDE.md TK-11431 amdt 3 — "this
# applies to the test harness too"). When BACKUP_TEST_MODE=1, SRC/DEST come from the env
# and the Henry mount-guard is relaxed so the test drives the real code path against a
# throwaway fixture. Guarded behind an explicit env flag the launchd plist NEVER sets, so a
# scheduled run can never silently measure a fixture and become the next false green.
TEST_MODE="${BACKUP_TEST_MODE:-0}"
HENRY="${BACKUP_HENRY:-/Volumes/Henry}"
DEST="${BACKUP_DEST:-$HENRY/mac2-archive/repo-backups}"
SRC="${BACKUP_SRC:-$HOME/Projects}"
SELF="$(cd "$(dirname "$0")" && pwd)"
# Guard the OUTPUT too: in TEST_MODE the heartbeat goes under the fixture DEST, never the
# real data/ dir — a seam that writes live latest.json would poison the fleet-health panel.
if [ "$TEST_MODE" = "1" ]; then DATA="${BACKUP_DATA:-$DEST}"; else DATA="$SELF/../data"; fi
mkdir -p "$DATA" 2>/dev/null || DATA=/tmp
LATEST="$DATA/repo-backup-latest.json"
# --- HARD GUARD: Henry must be a real mounted volume, or ABORT (never fall back to Mac2) ---
if [ "$TEST_MODE" != "1" ] && { [ ! -d "$HENRY" ] || ! mount | grep -q "on $HENRY "; }; then
printf '{"skill":"repo-backup","verdict":"FAIL","status":"FAIL","reason":"Henry NAS not mounted — aborted (refuse to write backups to Mac2)"}\n' > "$LATEST"
echo "FAIL: Henry not mounted at $HENRY — aborting (will NOT write to Mac2)"; exit 1
fi
mkdir -p "$DEST"
ok=0; fail=0; started=$(date +%s)
# Source set: every ~/Projects repo, PLUS ~/.claude and its 73 nested skill repos.
# ~/.claude was previously missed entirely (it is not under ~/Projects), leaving 452 skill
# definitions with no off-machine copy at all. Nested skill repos must be bundled
# INDIVIDUALLY: the parent records them as gitlinks, so a parent-only bundle captures a
# pointer and none of their content. (TK-11233 follow-up, 2026-09-10)
srcs=("$SRC"/*/.git)
# In real runs, ALSO back up ~/.claude and its nested skill repos (not under ~/Projects).
# In TEST_MODE, scan ONLY the fixture SRC so the negative test stays hermetic.
[ "$TEST_MODE" != "1" ] && srcs+=("$HOME/.claude/.git" "$HOME"/.claude/skills/*/.git)
for gd in "${srcs[@]}"; do
[ -d "$gd" ] || continue
repodir="$(dirname "$gd")"
case "$repodir" in
"$HOME/.claude") repo="dotclaude" ;;
"$HOME"/.claude/skills/*) repo="dotclaude-skill-$(basename "$repodir")" ;;
*) repo="$(basename "$repodir")" ;;
esac
tmp="$DEST/.$repo.bundle.tmp"; final="$DEST/$repo.bundle"
# TK-11648 IDENTITY GUARD: `git bundle verify` (below) proves the bundle is a VALID
# history — never that it is THIS repo's. If $repodir/.git is present-but-invalid (a
# hollow skeleton, no HEAD/objects), git walks UP to the nearest valid repo (the
# ~/Projects meta-repo) and `bundle create --all` bundles THAT under the child's name:
# a valid bundle of the WRONG repo, so the only off-machine copy is silently worthless
# while the run still reads PASS (confirmed: animals.bundle was a 1.6G copy of the
# meta-repo). Assert git resolves to THIS dir before bundling; a mismatch is a FAIL, not
# a silent wrong bundle. This measures what the bundle's NAME claims — exactly what
# `git bundle verify` structurally cannot.
want="$(cd "$repodir" 2>/dev/null && pwd -P)"
top="$(git -C "$repodir" rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$want" ] || [ -z "$top" ] || [ "$top" != "$want" ]; then
rm -f "$tmp" 2>/dev/null; fail=$((fail+1))
echo " FAIL: $repo has an invalid .git (git resolves to ${top:-<none>}, not $want) — NOT bundling"
continue
fi
if git -C "$(dirname "$gd")" bundle create "$tmp" --all >/dev/null 2>&1 && git -C "$(dirname "$gd")" bundle verify "$tmp" >/dev/null 2>&1; then
mv -f "$tmp" "$final"; ok=$((ok+1))
else
rm -f "$tmp" 2>/dev/null; fail=$((fail+1)); echo " WARN: bundle failed for $repo"
fi
done
elapsed=$(( $(date +%s) - started ))
total=$(du -sh "$DEST" 2>/dev/null | cut -f1)
verdict=PASS; [ "$fail" -gt 0 ] && verdict=WARN
printf '{"skill":"repo-backup","verdict":"%s","status":"%s","ts":"%s","repos_ok":%d,"repos_fail":%d,"total":"%s","dest":"%s","secs":%d}\n' \
"$verdict" "$verdict" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ok" "$fail" "$total" "$DEST" "$elapsed" > "$LATEST"
echo "[$verdict] backed up $ok repos ($fail failed) → $DEST total=$total in ${elapsed}s"