migrate: compliance-gated re-clone + per-machine state recovery script
Adds .claude/scripts/migrate-to-submodules.sh — self-contained, distributable by raw URL since old clones can't pull. Detects compliance (history merge-base vs origin, RECLONE.md+submodule offline fallback); leaves compliant clones untouched; otherwise re-clones AND recovers the gitignored per-machine state a clone never carries (identity.json, settings.local.json, .mcp.json, grepai, per-project .env/.venv/.attachments), surfaces stranded unpushed commits, and FLAGS large purged data for manual move (never re-imports it into git). Closes RECLONE.md's "recover any uncommitted work" gap that stranded identity.json + the discord-bot venv. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
.claude/scripts/migrate-to-submodules.sh
Normal file
204
.claude/scripts/migrate-to-submodules.sh
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
# ClaudeTools — re-clone migration + per-machine state recovery
|
||||
# ============================================================================
|
||||
# WHY THIS EXISTS
|
||||
# On 2026-06-18 claudetools history was REWRITTEN + force-pushed: every project
|
||||
# was split into its own Gitea repo (now git submodules) and large data purged
|
||||
# (.git 3.2GB -> ~80MB). An OLD clone (multi-GB .git, no submodules) has a
|
||||
# DISJOINT history and is INCOMPATIBLE — it must NOT /sync, /save, or push, or
|
||||
# it will create conflict messes. See RECLONE.md.
|
||||
#
|
||||
# This script is COMPLIANCE-GATED:
|
||||
# - Compliant (already on the new submodule layout) -> reports GREEN, exits,
|
||||
# touches NOTHING.
|
||||
# - Non-compliant (old clone) -> performs the documented re-clone AND, crucially,
|
||||
# recovers the gitignored PER-MACHINE state a clone never carries (identity.json,
|
||||
# settings.local.json, .mcp.json, grepai, per-project .env/.venv/.attachments).
|
||||
# RECLONE.md only says "recover any uncommitted work" as a comment — that gap is
|
||||
# what stranded identity.json and the discord-bot venv on GURU-BEAST-ROG.
|
||||
#
|
||||
# It must run on machines that CANNOT pull the new repo, so it is self-contained:
|
||||
# distribute by raw URL / coord / Discord, not by `git pull`.
|
||||
#
|
||||
# USAGE
|
||||
# bash migrate-to-submodules.sh --check # report compliance only (read-only)
|
||||
# bash migrate-to-submodules.sh # dry-run the migration plan
|
||||
# bash migrate-to-submodules.sh --confirm # actually migrate (mv + clone + recover)
|
||||
# bash migrate-to-submodules.sh --root /path/to/ClaudeTools # explicit target
|
||||
#
|
||||
# SAFETY
|
||||
# - Read-only until --confirm.
|
||||
# - Never deletes the old clone; renames it to <dir>.old and leaves it for you.
|
||||
# - Never copies large (>RECOVER_MAX_MB) gitignored data into the new clone or
|
||||
# into git — it FLAGS it for manual move to a file share (it was purged on purpose).
|
||||
# - Surfaces unpushed old-clone commits (which the rewrite stranded) before moving.
|
||||
# ============================================================================
|
||||
set -u
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
ok(){ echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn(){ echo -e "${YELLOW}[WARNING]${NC} $*"; }
|
||||
info(){ echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
err(){ echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
REPO_URL="https://git.azcomputerguru.com/azcomputerguru/claudetools.git"
|
||||
RECOVER_MAX_MB=300 # gitignored items bigger than this are FLAGGED, not auto-copied
|
||||
CONFIRM=0; CHECK_ONLY=0; ROOT=""
|
||||
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--confirm) CONFIRM=1 ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--root) ROOT="__NEXT__" ;;
|
||||
*) if [ "$ROOT" = "__NEXT__" ]; then ROOT="$a"; fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- locate the ClaudeTools directory ---------------------------------------
|
||||
if [ -z "$ROOT" ] || [ "$ROOT" = "__NEXT__" ]; then
|
||||
if [ -d ".git" ] && git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
elif [ -d "$HOME/ClaudeTools/.git" ]; then
|
||||
ROOT="$HOME/ClaudeTools"
|
||||
else
|
||||
err "Cannot locate a ClaudeTools clone. Pass --root /path/to/ClaudeTools"; exit 2
|
||||
fi
|
||||
fi
|
||||
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || { err "Root not found: $ROOT"; exit 2; }
|
||||
[ -d "$ROOT/.git" ] || { err "$ROOT is not a git repo"; exit 2; }
|
||||
info "Target clone: $ROOT"
|
||||
|
||||
# --- compliance check -------------------------------------------------------
|
||||
# Primary signal: does this clone share history with the rewritten remote?
|
||||
# fetch origin -> if there is NO merge-base between HEAD and origin/main the
|
||||
# histories are DISJOINT == old incompatible clone.
|
||||
# Offline fallback: new repo carries RECLONE.md + projects/* submodules.
|
||||
COMPLIANCE="unknown"; COMPLIANCE_WHY=""
|
||||
detect_compliance() {
|
||||
local has_marker=0
|
||||
if [ -f "$ROOT/RECLONE.md" ] && git -C "$ROOT" config --file "$ROOT/.gitmodules" --get-regexp 'submodule\..*\.path' 2>/dev/null | grep -q 'projects/'; then
|
||||
has_marker=1
|
||||
fi
|
||||
if git -C "$ROOT" fetch --quiet origin main 2>/dev/null; then
|
||||
if git -C "$ROOT" merge-base HEAD FETCH_HEAD >/dev/null 2>&1; then
|
||||
COMPLIANCE="compliant"; COMPLIANCE_WHY="shares history with rewritten origin/main"
|
||||
else
|
||||
COMPLIANCE="noncompliant"; COMPLIANCE_WHY="DISJOINT history vs origin/main (old pre-rewrite clone)"
|
||||
fi
|
||||
else
|
||||
# offline — fall back to structural markers
|
||||
if [ "$has_marker" = 1 ]; then
|
||||
COMPLIANCE="compliant"; COMPLIANCE_WHY="offline: RECLONE.md + project submodules present"
|
||||
else
|
||||
COMPLIANCE="noncompliant"; COMPLIANCE_WHY="offline: no RECLONE.md / no project submodules (looks like old layout)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
detect_compliance
|
||||
|
||||
echo
|
||||
if [ "$COMPLIANCE" = "compliant" ]; then
|
||||
ok "COMPLIANT — $COMPLIANCE_WHY"
|
||||
ok "Nothing to do. This clone is on the new submodule layout."
|
||||
exit 0
|
||||
fi
|
||||
warn "NON-COMPLIANT — $COMPLIANCE_WHY"
|
||||
warn "This clone is incompatible with the rewritten repo. DO NOT /sync, /save, or push from it."
|
||||
[ "$CHECK_ONLY" = 1 ] && { info "--check only; not migrating."; exit 1; }
|
||||
|
||||
# --- pre-flight: stranded unpushed commits (rewrite can't accept them) -------
|
||||
echo; info "Pre-flight: scanning the old clone for unpushed local commits..."
|
||||
UNPUSHED="$(git -C "$ROOT" log --oneline @{u}..HEAD 2>/dev/null || git -C "$ROOT" log --oneline origin/main..HEAD 2>/dev/null || true)"
|
||||
if [ -n "$UNPUSHED" ]; then
|
||||
warn "Old clone has local commits that the rewrite will NOT accept — re-apply them by hand after migrating:"
|
||||
echo "$UNPUSHED" | sed 's/^/ /'
|
||||
BUNDLE="$ROOT.unpushed-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null).bundle"
|
||||
info "These are preserved in your old clone's history; you can also bundle them: git -C '$ROOT' bundle create '$BUNDLE' origin/main..HEAD"
|
||||
else
|
||||
ok "No unpushed commits in the old clone."
|
||||
fi
|
||||
|
||||
# --- the recovery allowlist (per-machine gitignored state a clone never carries)
|
||||
# Small, machine-specific config + service runtime. NOT client data.
|
||||
RECOVER_FILES=(
|
||||
".claude/identity.json"
|
||||
".claude/settings.local.json"
|
||||
".mcp.json"
|
||||
"grepai.exe"
|
||||
)
|
||||
RECOVER_DIRS=( ".grepai" )
|
||||
# per-project runtime, matched anywhere under the tree (depth-limited):
|
||||
RUNTIME_GLOBS=( ".env" ".venv" ".attachments" )
|
||||
|
||||
OLD="$ROOT.old"; FRESH="$ROOT"
|
||||
if [ "$CONFIRM" != 1 ]; then
|
||||
echo; warn "DRY RUN — plan (re-run with --confirm to execute):"
|
||||
echo " 1. mv '$ROOT' '$OLD'"
|
||||
echo " 2. git clone $REPO_URL '$FRESH'"
|
||||
echo " 3. recover per-machine config from .old: ${RECOVER_FILES[*]} ${RECOVER_DIRS[*]}"
|
||||
echo " 4. git -C '$FRESH' submodule update --init --recursive"
|
||||
echo " 5. recover per-project runtime (${RUNTIME_GLOBS[*]}) from .old"
|
||||
echo " 6. reconcile git identity; 7. FLAG large gitignored data (>${RECOVER_MAX_MB}MB) for manual move"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- execute ----------------------------------------------------------------
|
||||
command -v git >/dev/null || { err "git not found"; exit 2; }
|
||||
echo; info "Migrating (mv + clone)..."
|
||||
mv "$ROOT" "$OLD" || { err "mv failed"; exit 2; }
|
||||
if ! git clone "$REPO_URL" "$FRESH"; then
|
||||
err "clone failed — restoring old clone"; mv "$OLD" "$ROOT"; exit 2
|
||||
fi
|
||||
|
||||
info "Recovering per-machine config from $OLD ..."
|
||||
for f in "${RECOVER_FILES[@]}"; do
|
||||
if [ -f "$OLD/$f" ]; then mkdir -p "$FRESH/$(dirname "$f")"; cp -p "$OLD/$f" "$FRESH/$f" && ok " recovered $f"; fi
|
||||
done
|
||||
for d in "${RECOVER_DIRS[@]}"; do
|
||||
if [ -d "$OLD/$d" ]; then cp -a "$OLD/$d" "$FRESH/$(dirname "$d")/" && ok " recovered $d/"; fi
|
||||
done
|
||||
|
||||
info "Initializing submodules..."
|
||||
git -C "$FRESH" submodule update --init --recursive || warn "submodule init had issues — re-run: git -C '$FRESH' submodule update --init --recursive"
|
||||
|
||||
info "Recovering per-project runtime (.env/.venv/.attachments) from $OLD ..."
|
||||
# depth-limited so we don't descend into .venv/node_modules; recover only small items.
|
||||
for pat in "${RUNTIME_GLOBS[@]}"; do
|
||||
while IFS= read -r src; do
|
||||
[ -e "$src" ] || continue
|
||||
rel="${src#$OLD/}"; dst="$FRESH/$rel"
|
||||
[ -e "$dst" ] && continue
|
||||
szmb=$(( $(du -sm "$src" 2>/dev/null | cut -f1) ))
|
||||
if [ "$szmb" -gt "$RECOVER_MAX_MB" ]; then warn " SKIP (>${RECOVER_MAX_MB}MB) $rel — flagged below"; continue; fi
|
||||
mkdir -p "$(dirname "$dst")"; cp -a "$src" "$dst" && ok " recovered $rel (${szmb}MB)"
|
||||
done < <(find "$OLD" -maxdepth 5 -name "$pat" -not -path '*/.git/*' 2>/dev/null)
|
||||
done
|
||||
|
||||
info "Reconciling git identity..."
|
||||
if [ -x "$FRESH/.claude/scripts/migrate-identity.sh" ]; then bash "$FRESH/.claude/scripts/migrate-identity.sh" >/dev/null 2>&1 || true; fi
|
||||
NAME="$(jq -r '.full_name // empty' "$FRESH/.claude/identity.json" 2>/dev/null)"
|
||||
MAIL="$(jq -r '.email // empty' "$FRESH/.claude/identity.json" 2>/dev/null)"
|
||||
[ -n "$NAME" ] && git -C "$FRESH" config user.name "$NAME" && ok " git user.name = $NAME"
|
||||
[ -n "$MAIL" ] && git -C "$FRESH" config user.email "$MAIL" && ok " git user.email = $MAIL"
|
||||
|
||||
# --- flag large gitignored data (DO NOT auto-import — it was purged on purpose)
|
||||
echo; info "Scanning old clone for LARGE gitignored data (manual preservation needed)..."
|
||||
FLAGGED=0
|
||||
while IFS= read -r line; do
|
||||
rel="$(echo "$line" | sed -E 's/^(!!|\?\?) //')"; [ -z "$rel" ] && continue
|
||||
case "$rel" in .git/*|*/.git/*|.claude/tmp/*) continue;; esac
|
||||
src="$OLD/$rel"; [ -e "$src" ] || continue
|
||||
szmb=$(( $(du -sm "$src" 2>/dev/null | cut -f1) ))
|
||||
if [ "$szmb" -ge "$RECOVER_MAX_MB" ]; then warn " ${szmb}MB $rel"; FLAGGED=$((FLAGGED+1)); fi
|
||||
done < <(git -C "$OLD" -c core.quotepath=false status --ignored --porcelain 2>/dev/null | grep -E '^(!!|\?\?)')
|
||||
if [ "$FLAGGED" -gt 0 ]; then
|
||||
warn "$FLAGGED large gitignored item(s) above are NOT in git and NOT in the Jupiter bundle."
|
||||
warn "If $OLD is their only copy, MOVE them to a file share BEFORE deleting it. Do NOT add them to git."
|
||||
else
|
||||
ok "No large gitignored data to preserve."
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "Migration complete. New clone: $FRESH Old clone (review then delete): $OLD"
|
||||
info "Verify: SELFCHECK_TS=\"\$(date -u +%Y-%m-%dT%H:%M:%SZ)\" bash '$FRESH/.claude/skills/self-check/scripts/self-check.sh' report"
|
||||
info "Then restart any per-machine services (e.g. nssm restart <svc>) and finally: rm -rf '$OLD'"
|
||||
16
RECLONE.md
16
RECLONE.md
@@ -4,13 +4,25 @@ History was rewritten: every project split into its own Gitea repo (now git **su
|
||||
and large data/history purged. `.git` went 3.2 GB → ~80 MB. An OLD clone (multi-GB `.git`,
|
||||
no submodules) is **incompatible** — do NOT `/sync`, `/save`, or `git push` from it.
|
||||
|
||||
Re-clone:
|
||||
Automated (preferred) — compliance-gated; leaves a compliant clone untouched, otherwise
|
||||
re-clones AND recovers the gitignored per-machine state a clone never carries (identity.json,
|
||||
settings.local.json, .mcp.json, grepai, per-project .env/.venv/.attachments) and flags large
|
||||
purged data for manual preservation:
|
||||
```
|
||||
bash .claude/scripts/migrate-to-submodules.sh --check # report compliance only (read-only)
|
||||
bash .claude/scripts/migrate-to-submodules.sh # dry-run the plan
|
||||
bash .claude/scripts/migrate-to-submodules.sh --confirm # execute
|
||||
```
|
||||
An OLD clone can't pull this script — grab it by raw URL:
|
||||
`https://git.azcomputerguru.com/azcomputerguru/claudetools/raw/branch/main/.claude/scripts/migrate-to-submodules.sh`
|
||||
|
||||
Manual equivalent:
|
||||
```
|
||||
cd <parent>; mv ClaudeTools ClaudeTools.old
|
||||
git clone https://git.azcomputerguru.com/azcomputerguru/claudetools.git ClaudeTools
|
||||
cp ClaudeTools.old/.claude/identity.json ClaudeTools/.claude/ # gitignored, per-machine — keep it
|
||||
cd ClaudeTools && git submodule update --init --recursive
|
||||
# recover any uncommitted work from ClaudeTools.old, then delete it
|
||||
# recover gitignored per-machine state (.env/.venv/.attachments/settings.local.json/.mcp.json) from .old, then delete it
|
||||
```
|
||||
Project history now lives in the submodules under `projects/`. Full pre-split backup bundle:
|
||||
`\172.16.3.20\Backups\Gitea-Storage\claudetools-pre-split-2026-06-18.bundle`
|
||||
|
||||
Reference in New Issue
Block a user