[object Object]

← 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

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 →