← back to Nas Setup
backup-repos-to-henry: cover ~/.claude and its 73 nested skill repos
7d6456f3e3ba25e173308186416a7190cfa2e51a · 2026-09-10 13:06:42 -0700 · Steve Abrams
The source glob was $HOME/Projects/*/.git, so ~/.claude was missed entirely by
path - leaving 452 skill definitions with NO off-machine copy of any kind. A
full audit found nothing else covered them either: Time Machine has no
destination configured, no launchd job writes ~/.claude, and rclone/iCloud/
Syncthing/cron are all absent or dead. The only off-machine artifact was a
6-month-stale MD-only zip.
Nested skill repos are bundled INDIVIDUALLY because the parent repo records
them as gitlinks - a parent-only bundle captures a 1-line pointer and none of
their content. 73 skills are affected.
Bundle names are namespaced (dotclaude, dotclaude-skill-<name>) so they cannot
collide with a ~/Projects repo of the same basename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
A scripts/backup-repos-to-henry.sh
Diff
commit 7d6456f3e3ba25e173308186416a7190cfa2e51a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 13:06:42 2026 -0700
backup-repos-to-henry: cover ~/.claude and its 73 nested skill repos
The source glob was $HOME/Projects/*/.git, so ~/.claude was missed entirely by
path - leaving 452 skill definitions with NO off-machine copy of any kind. A
full audit found nothing else covered them either: Time Machine has no
destination configured, no launchd job writes ~/.claude, and rclone/iCloud/
Syncthing/cron are all absent or dead. The only off-machine artifact was a
6-month-stale MD-only zip.
Nested skill repos are bundled INDIVIDUALLY because the parent repo records
them as gitlinks - a parent-only bundle captures a 1-line pointer and none of
their content. 73 skills are affected.
Bundle names are namespaced (dotclaude, dotclaude-skill-<name>) so they cannot
collide with a ~/Projects repo of the same basename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
scripts/backup-repos-to-henry.sh | 52 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 52 insertions(+)
diff --git a/scripts/backup-repos-to-henry.sh b/scripts/backup-repos-to-henry.sh
new file mode 100755
index 0000000..ac74665
--- /dev/null
+++ b/scripts/backup-repos-to-henry.sh
@@ -0,0 +1,52 @@
+#!/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
+HENRY=/Volumes/Henry
+DEST="$HENRY/mac2-archive/repo-backups"
+SRC="$HOME/Projects"
+SELF="$(cd "$(dirname "$0")" && pwd)"
+DATA="$SELF/../data"; 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 [ ! -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)
+for gd in "$SRC"/*/.git "$HOME/.claude/.git" "$HOME"/.claude/skills/*/.git; 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"
+ 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"
← 1256a2d backup-daemon-health: credit the user belt via its verified
·
back to Nas Setup
·
daemon-health: watch the repo-bundle belt, not just the two e6b27ef →