#!/usr/bin/env bash # muse-git-backup.sh — git-bridge backup mirror for muse/musehub -> GitHub # See MuseHub staging issue #127 for full context. # # Usage: # ./muse-git-backup.sh preflight # ./muse-git-backup.sh replay-muse [--skip N] [--limit N] # ./muse-git-backup.sh replay-musehub [--skip N] [--limit N] # ./muse-git-backup.sh replay-all [--skip N] [--limit N] # muse + musehub in parallel # ./muse-git-backup.sh github-push-muse # ./muse-git-backup.sh github-push-musehub # ./muse-git-backup.sh github-push # both # ./muse-git-backup.sh verify-muse # ./muse-git-backup.sh verify-musehub # ./muse-git-backup.sh verify # both # ./muse-git-backup.sh incremental-muse # ./muse-git-backup.sh incremental-musehub # # replay-* is resumable by default: it inspects how many commits are already # on the target mirror branch and skips that many automatically, so running # `replay-muse --limit 20` as a dry run and then `replay-muse` again to do # the rest continues where the dry run left off instead of duplicating it. # Pass --skip explicitly to override the auto-detected count. # # Each subcommand is independent and safe to re-run. This script never # touches ~/ecosystem/muse or ~/ecosystem/musehub — every operation targets # the copies under /Users/gabriel/dev/copies 2/ and brand-new directories/ # GitHub repos this script creates fresh. Run as ./muse-git-backup.sh , # never sourced into your interactive shell — that's what makes `exit` # inside this script safe (it only ends the script's own process). set -euo pipefail MUSE_COPY="/Users/gabriel/dev/copies 2/muse 04" MUSEHUB_COPY="/Users/gabriel/dev/copies 2/musehub 04" MUSE_GIT_BACKUP="/Users/gabriel/dev/copies 2/muse-git-backup" MUSEHUB_GIT_BACKUP="/Users/gabriel/dev/copies 2/musehub-git-backup" GH_MUSE_REPO="cgcardona/muse-backup" GH_MUSEHUB_REPO="cgcardona/musehub-backup" guard() { case "$1" in *ecosystem*) echo "❌ REFUSED: path contains 'ecosystem': $1"; exit 1 ;; esac } cmd_preflight() { guard "$MUSE_COPY" guard "$MUSEHUB_COPY" test -d "$MUSE_COPY/.muse" && echo "muse copy: OK" || { echo "muse copy: MISSING .muse/ — STOP"; exit 1; } test -d "$MUSEHUB_COPY/.muse" && echo "musehub copy: OK" || { echo "musehub copy: MISSING .muse/ — STOP"; exit 1; } if [ -L "$MUSE_COPY" ]; then echo "STOP — muse copy is a symlink"; exit 1; fi if [ -L "$MUSEHUB_COPY" ]; then echo "STOP — musehub copy is a symlink"; exit 1; fi echo "muse copy: real directory, not a symlink" echo "musehub copy: real directory, not a symlink" echo "muse commit count:" muse -C "$MUSE_COPY" rev-list --count HEAD --json echo "musehub commit count:" muse -C "$MUSEHUB_COPY" rev-list --count HEAD --json } _replay_full_history() { local muse_copy="$1" git_backup="$2" label="$3" guard "$muse_copy" guard "$git_backup" mkdir -p "$git_backup" local already_exported=0 if [ ! -d "$git_backup/.git" ]; then git -C "$git_backup" init -q git -C "$git_backup" config user.email "gabriel@tellurstori.com" git -C "$git_backup" config user.name "gabriel" git -C "$git_backup" commit --allow-empty -qm "init ($label git-bridge backup mirror)" else already_exported=$(git -C "$git_backup" rev-list --count muse-mirror 2>/dev/null || echo 1) already_exported=$((already_exported - 1)) [ "$already_exported" -lt 0 ] && already_exported=0 fi local commits total n skip commits=$(muse -C "$muse_copy" rev-list --reverse HEAD --json | python3 -c "import sys,json; print('\n'.join(json.load(sys.stdin)['commit_ids']))") skip="${SKIP:-$already_exported}" if [ "$skip" -gt 0 ]; then echo "Skipping first $skip commits already on $label-mirror" commits=$(echo "$commits" | tail -n "+$((skip + 1))") fi if [ -z "$commits" ]; then echo "Nothing to export — $label-mirror is already fully replayed." return 0 fi if [ -n "${LIMIT:-}" ]; then # head -n via a pipe can SIGPIPE the writer on large input (head exits # once it has its N lines, closing its read end while echo is still # blocked writing the rest) -- a here-string avoids the pipe entirely. # Only surfaced at real scale (1459 commits); tiny test fixtures never # produced enough output to hit the pipe-buffer boundary. commits=$(head -n "$LIMIT" <<< "$commits") echo "LIMIT=$LIMIT set — replaying only $LIMIT commits from this point (dry run)" fi total=$(echo "$commits" | wc -l | tr -d ' ') n=0 while IFS= read -r cid; do n=$((n + 1)) echo "[$n/$total] exporting $cid" (cd "$muse_copy" && muse bridge git-export \ --muse-ref "$cid" \ --git-dir "$git_backup" \ --git-branch muse-mirror \ --no-push \ --allow-empty \ --json) done <<< "$commits" echo "git history length:" git -C "$git_backup" rev-list --count muse-mirror } cmd_replay_muse() { _replay_full_history "$MUSE_COPY" "$MUSE_GIT_BACKUP" "muse" } cmd_replay_musehub() { _replay_full_history "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" "musehub" } cmd_replay_all() { local log_muse log_musehub log_muse=$(mktemp) log_musehub=$(mktemp) echo "Replaying muse and musehub in parallel — logs: $log_muse , $log_musehub" ( _replay_full_history "$MUSE_COPY" "$MUSE_GIT_BACKUP" "muse" > "$log_muse" 2>&1 ) & local pid_muse=$! ( _replay_full_history "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" "musehub" > "$log_musehub" 2>&1 ) & local pid_musehub=$! wait "$pid_muse" && echo "✅ muse replay finished" || echo "🛑 muse replay FAILED — see $log_muse" wait "$pid_musehub" && echo "✅ musehub replay finished" || echo "🛑 musehub replay FAILED — see $log_musehub" echo "--- muse log ---" cat "$log_muse" echo "--- musehub log ---" cat "$log_musehub" } _github_push_one() { local git_backup="$1" gh_repo="$2" description="$3" guard "$git_backup" gh auth status || { echo "Run 'gh auth login' first"; exit 1; } # public -- muse and musehub are developed in public. gh repo view "$gh_repo" >/dev/null 2>&1 || gh repo create "$gh_repo" --public --description "$description" # https, not git@github.com SSH -- matches `gh auth status`'s reported # git protocol, which uses gh's own credential helper (no SSH key setup # required). git -C "$git_backup" remote get-url origin >/dev/null 2>&1 || git -C "$git_backup" remote add origin "https://github.com/$gh_repo.git" git -C "$git_backup" push -u origin muse-mirror } cmd_github_push_muse() { _github_push_one "$MUSE_GIT_BACKUP" "$GH_MUSE_REPO" "Git-bridge backup mirror of the muse Muse repo" } cmd_github_push_musehub() { _github_push_one "$MUSEHUB_GIT_BACKUP" "$GH_MUSEHUB_REPO" "Git-bridge backup mirror of the musehub Muse repo" } cmd_github_push() { cmd_github_push_muse cmd_github_push_musehub } _verify_one() { local muse_copy="$1" gh_repo="$2" clone_dir="$3" label="$4" guard "$muse_copy" rm -rf "$clone_dir" git clone "https://github.com/$gh_repo.git" "$clone_dir" git -C "$clone_dir" checkout muse-mirror echo "--- $label diff (expect no output) ---" diff -rq --exclude=.git --exclude=.muse "$muse_copy" "$clone_dir" || true } cmd_verify_muse() { _verify_one "$MUSE_COPY" "$GH_MUSE_REPO" /tmp/verify-muse-backup muse } cmd_verify_musehub() { _verify_one "$MUSEHUB_COPY" "$GH_MUSEHUB_REPO" /tmp/verify-musehub-backup musehub } cmd_verify() { cmd_verify_muse cmd_verify_musehub } _incremental() { local muse_copy="$1" git_backup="$2" local last_bridged new_commits guard "$muse_copy" guard "$git_backup" last_bridged=$(python3 -c " import tomllib with open('$muse_copy/.muse/git-bridge.toml', 'rb') as f: state = tomllib.load(f) print(state['last_export']['muse_commit_id']) ") echo "Last bridged commit: $last_bridged" new_commits=$(muse -C "$muse_copy" rev-list "$last_bridged..HEAD" --reverse --json | python3 -c "import sys,json; print('\n'.join(json.load(sys.stdin)['commit_ids']))") if [ -z "$new_commits" ]; then echo "Nothing new since the last backup — done." return 0 fi while IFS= read -r cid; do echo "exporting $cid" (cd "$muse_copy" && muse bridge git-export \ --muse-ref "$cid" \ --git-dir "$git_backup" \ --git-branch muse-mirror \ --no-push \ --allow-empty \ --json) done <<< "$new_commits" git -C "$git_backup" push origin muse-mirror } cmd_incremental_muse() { _incremental "$MUSE_COPY" "$MUSE_GIT_BACKUP" } cmd_incremental_musehub() { _incremental "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" } SUBCOMMAND="${1:-}" shift || true LIMIT="" SKIP="" while [ $# -gt 0 ]; do case "$1" in --limit) LIMIT="${2:?--limit requires a number}"; shift 2 ;; --skip) SKIP="${2:?--skip requires a number}"; shift 2 ;; *) echo "Unknown flag: $1"; exit 1 ;; esac done case "$SUBCOMMAND" in preflight) cmd_preflight ;; replay-muse) cmd_replay_muse ;; replay-musehub) cmd_replay_musehub ;; replay-all) cmd_replay_all ;; github-push-muse) cmd_github_push_muse ;; github-push-musehub) cmd_github_push_musehub ;; github-push) cmd_github_push ;; verify-muse) cmd_verify_muse ;; verify-musehub) cmd_verify_musehub ;; verify) cmd_verify ;; incremental-muse) cmd_incremental_muse ;; incremental-musehub) cmd_incremental_musehub ;; *) echo "Usage: $0 {preflight|replay-muse|replay-musehub|replay-all|github-push-muse|github-push-musehub|github-push|verify-muse|verify-musehub|verify|incremental-muse|incremental-musehub} [--skip N] [--limit N]" exit 1 ;; esac