Files
gstack/bin/gstack-brain-restore
garrytan cd7485b0b4 W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.
2026-09-30 06:38:50 +00:00

249 lines
8.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-brain-restore — bootstrap a new machine from an existing brain repo.
#
# Usage:
# gstack-brain-restore [<git-remote-url>]
#
# If no URL is given, reads from ~/.gstack-brain-remote.txt (written by
# gstack-brain-init on the original machine). Copy that file to the new
# machine before running this command.
#
# Safety gates (refuses with clear message):
# - ~/.gstack/.git already exists with a DIFFERENT remote
# - ~/.gstack/ contains non-allowlisted, non-gitignored user files
# that would be clobbered by restore
#
# What it does:
# 1. Clone the remote to a staging directory
# 2. Validate the repo is gstack-brain-shaped (.brain-allowlist, .gitattributes)
# 3. rsync-copy tracked files into ~/.gstack/ with skip-if-same-hash
# 4. Move staging's .git into ~/.gstack/.git
# 5. Register local git config merge drivers (they don't clone from remote)
# 6. Wire the cloned brain into gbrain via gstack-gbrain-source-wireup
# (best-effort; restore continues even if gbrain wireup fails)
#
# Env:
# GSTACK_HOME — override ~/.gstack
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
# pipe in the forked child before exec, with no reader on the other end. On
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
# body >=512B blocks write() forever and the script hangs at startup with no
# output. Compat level 50 restores the tempfile path. These scripts are
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
# guard is per-script, and it survives `bash script.sh` call sites that
# bypass the shebang.
BASH_COMPAT=50
set -euo pipefail
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
# Egress receipt helpers (_receipted_git): fail-open for user-directed
# git ops against the user's own artifacts remote.
. "$SCRIPT_DIR/gstack-egress-lib.sh"
# v1.27.0.0+ canonical name; brain-remote is the legacy fallback during the
# migration window. The migration script renames the file in place.
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
else
REMOTE_FILE="$HOME/.gstack-brain-remote.txt"
fi
REMOTE_URL="${1:-}"
if [ -z "$REMOTE_URL" ]; then
if [ -f "$REMOTE_FILE" ]; then
REMOTE_URL=$(head -1 "$REMOTE_FILE" | tr -d '[:space:]')
fi
fi
if [ -z "$REMOTE_URL" ]; then
cat >&2 <<EOF
gstack-brain-restore: no remote URL provided.
Provide one of:
gstack-brain-restore <git-url>
or put the URL in $REMOTE_FILE (copy from the original machine)
EOF
exit 1
fi
# ---- safety gates ----
if [ -d "$GSTACK_HOME/.git" ]; then
EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "")
if [ -n "$EXISTING_REMOTE" ] && [ "$EXISTING_REMOTE" != "$REMOTE_URL" ]; then
cat >&2 <<EOF
gstack-brain-restore: ~/.gstack/.git already points at:
$EXISTING_REMOTE
You asked to restore from:
$REMOTE_URL
Refusing to overwrite. Run 'gstack-brain-uninstall' first or pass a matching URL.
EOF
exit 1
fi
fi
# ---- clone to staging ----
STAGING=$(mktemp -d "${TMPDIR:-/tmp}/gstack-brain-restore.XXXXXX")
trap 'rm -rf "$STAGING" 2>/dev/null' EXIT
echo "Cloning $REMOTE_URL to staging..."
RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}"
if ! _receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-clone "user ran gstack-brain-restore" \
bash -c 'git clone --quiet "$1" "$2" 2>/dev/null' _ "$REMOTE_URL" "$STAGING/repo"; then
echo "Clone failed. Check:" >&2
echo " - URL is correct: $REMOTE_URL" >&2
echo " - Auth: gh auth status (github) / glab auth status (gitlab)" >&2
exit 1
fi
# ---- validate shape ----
if [ ! -f "$STAGING/repo/.brain-allowlist" ] || [ ! -f "$STAGING/repo/.gitattributes" ]; then
cat >&2 <<EOF
gstack-brain-restore: $REMOTE_URL does not look like a gstack-brain repo.
Missing: .brain-allowlist and/or .gitattributes
This command only works on repos created by gstack-brain-init.
EOF
exit 1
fi
# ---- validate target ~/.gstack/ has no non-gitignored user files ----
mkdir -p "$GSTACK_HOME"
if [ ! -d "$GSTACK_HOME/.git" ]; then
# No existing git → check if we'd clobber anything allowlisted.
# Read the new allowlist globs and see if any existing files would collide.
CLOBBER_RISK=$(python3 - "$GSTACK_HOME" "$STAGING/repo/.brain-allowlist" <<'PYEOF'
import sys, os, fnmatch
home, allowlist_path = sys.argv[1:3]
try:
with open(allowlist_path) as f:
globs = [l.strip() for l in f if l.strip() and not l.lstrip().startswith('#')]
except FileNotFoundError:
globs = []
risks = []
for root, dirs, files in os.walk(home):
dirs[:] = [d for d in dirs if d != '.git']
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, home)
for g in globs:
if fnmatch.fnmatchcase(rel, g):
risks.append(rel)
break
for r in risks[:5]:
print(r)
if len(risks) > 5:
print(f"...and {len(risks) - 5} more")
sys.exit(0 if not risks else 2)
PYEOF
) || true
if [ -n "$CLOBBER_RISK" ]; then
cat >&2 <<EOF
gstack-brain-restore: ~/.gstack/ has existing allowlisted files that would
be clobbered by restore:
$CLOBBER_RISK
Back these up first, or run this command on a machine with an empty
~/.gstack/. If these files are from an earlier gstack session on THIS
machine, you probably want to run gstack-brain-init instead (to create a
new brain repo with this machine's state).
EOF
exit 1
fi
fi
# ---- copy tracked files in ----
echo "Copying tracked files into ~/.gstack/ ..."
# Use git-ls-tree to get exact tracked file list (avoids staged/untracked files).
cd "$STAGING/repo"
git ls-tree -r --name-only HEAD | while IFS= read -r rel_path; do
src="$STAGING/repo/$rel_path"
dst="$GSTACK_HOME/$rel_path"
mkdir -p "$(dirname "$dst")"
# Skip if identical (content hash). Otherwise copy.
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
continue
fi
cp "$src" "$dst"
done
# ---- move .git into place ----
if [ -d "$GSTACK_HOME/.git" ]; then
# Existing .git with matching remote — just fetch + fast-forward.
_receipted_git open brain-restore "${RESTORE_HOST:-unknown}" brain-restore-fetch "user ran gstack-brain-restore" \
bash -c 'git -C "$1" fetch origin >/dev/null 2>&1' _ "$GSTACK_HOME" || true
else
mv "$STAGING/repo/.git" "$GSTACK_HOME/.git"
fi
# ---- register merge drivers (local git config; don't survive clones) ----
git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B"
git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger"
git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A"
git -C "$GSTACK_HOME" config merge.union.name "union concat"
# ---- install pre-commit hook (same as init) ----
HOOK="$GSTACK_HOME/.git/hooks/pre-commit"
mkdir -p "$(dirname "$HOOK")"
cat > "$HOOK" <<'HOOK_EOF'
#!/usr/bin/env bash
set -uo pipefail
python3 -c "
import sys, re, subprocess
try:
out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace')
except Exception:
sys.exit(0)
patterns = [
('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')),
('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')),
('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')),
('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')),
('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')),
('bearer-token-json',
re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"',
re.IGNORECASE)),
]
for name, rx in patterns:
if rx.search(out):
sys.stderr.write(f'gstack-brain pre-commit: refusing commit — {name} detected.\n')
sys.exit(1)
sys.exit(0)
"
HOOK_EOF
chmod +x "$HOOK"
# ---- write remote helper file if missing ----
if [ ! -f "$REMOTE_FILE" ]; then
echo "$REMOTE_URL" > "$REMOTE_FILE"
chmod 600 "$REMOTE_FILE"
echo ""
echo "Wrote $REMOTE_FILE for future skill-run auto-detection."
fi
# ---- wire the cloned brain into gbrain (best-effort) ----
WIREUP_BIN="$SCRIPT_DIR/gstack-gbrain-source-wireup"
if [ -x "$WIREUP_BIN" ]; then
"$WIREUP_BIN" || >&2 echo "WARNING: gbrain wireup failed; run $WIREUP_BIN manually after fixing prereqs"
fi
cat <<EOF
gstack-brain-restore complete.
Local: $GSTACK_HOME
Remote: $REMOTE_URL
Next skill run will ask about privacy mode (one-time question) and then
sync automatically at skill boundaries.
Status anytime: gstack-brain-sync --status
EOF