muse-git-backup.sh
bash
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
9 days ago
| 1 | #!/usr/bin/env bash |
| 2 | # muse-git-backup.sh — git-bridge backup mirror for muse/musehub -> GitHub |
| 3 | # See MuseHub staging issue #127 for full context. |
| 4 | # |
| 5 | # Usage: |
| 6 | # ./muse-git-backup.sh preflight |
| 7 | # ./muse-git-backup.sh replay-muse [--skip N] [--limit N] |
| 8 | # ./muse-git-backup.sh replay-musehub [--skip N] [--limit N] |
| 9 | # ./muse-git-backup.sh replay-all [--skip N] [--limit N] # muse + musehub in parallel |
| 10 | # ./muse-git-backup.sh github-push-muse |
| 11 | # ./muse-git-backup.sh github-push-musehub |
| 12 | # ./muse-git-backup.sh github-push # both |
| 13 | # ./muse-git-backup.sh verify-muse |
| 14 | # ./muse-git-backup.sh verify-musehub |
| 15 | # ./muse-git-backup.sh verify # both |
| 16 | # ./muse-git-backup.sh incremental-muse |
| 17 | # ./muse-git-backup.sh incremental-musehub |
| 18 | # |
| 19 | # replay-* is resumable by default: it inspects how many commits are already |
| 20 | # on the target mirror branch and skips that many automatically, so running |
| 21 | # `replay-muse --limit 20` as a dry run and then `replay-muse` again to do |
| 22 | # the rest continues where the dry run left off instead of duplicating it. |
| 23 | # Pass --skip explicitly to override the auto-detected count. |
| 24 | # |
| 25 | # Each subcommand is independent and safe to re-run. This script never |
| 26 | # touches ~/ecosystem/muse or ~/ecosystem/musehub — every operation targets |
| 27 | # the copies under /Users/gabriel/dev/copies 2/ and brand-new directories/ |
| 28 | # GitHub repos this script creates fresh. Run as ./muse-git-backup.sh <cmd>, |
| 29 | # never sourced into your interactive shell — that's what makes `exit` |
| 30 | # inside this script safe (it only ends the script's own process). |
| 31 | |
| 32 | set -euo pipefail |
| 33 | |
| 34 | MUSE_COPY="/Users/gabriel/dev/copies 2/muse 04" |
| 35 | MUSEHUB_COPY="/Users/gabriel/dev/copies 2/musehub 04" |
| 36 | MUSE_GIT_BACKUP="/Users/gabriel/dev/copies 2/muse-git-backup" |
| 37 | MUSEHUB_GIT_BACKUP="/Users/gabriel/dev/copies 2/musehub-git-backup" |
| 38 | GH_MUSE_REPO="cgcardona/muse-backup" |
| 39 | GH_MUSEHUB_REPO="cgcardona/musehub-backup" |
| 40 | |
| 41 | guard() { |
| 42 | case "$1" in |
| 43 | *ecosystem*) echo "❌ REFUSED: path contains 'ecosystem': $1"; exit 1 ;; |
| 44 | esac |
| 45 | } |
| 46 | |
| 47 | cmd_preflight() { |
| 48 | guard "$MUSE_COPY" |
| 49 | guard "$MUSEHUB_COPY" |
| 50 | |
| 51 | test -d "$MUSE_COPY/.muse" && echo "muse copy: OK" || { echo "muse copy: MISSING .muse/ — STOP"; exit 1; } |
| 52 | test -d "$MUSEHUB_COPY/.muse" && echo "musehub copy: OK" || { echo "musehub copy: MISSING .muse/ — STOP"; exit 1; } |
| 53 | |
| 54 | if [ -L "$MUSE_COPY" ]; then echo "STOP — muse copy is a symlink"; exit 1; fi |
| 55 | if [ -L "$MUSEHUB_COPY" ]; then echo "STOP — musehub copy is a symlink"; exit 1; fi |
| 56 | echo "muse copy: real directory, not a symlink" |
| 57 | echo "musehub copy: real directory, not a symlink" |
| 58 | |
| 59 | echo "muse commit count:" |
| 60 | muse -C "$MUSE_COPY" rev-list --count HEAD --json |
| 61 | echo "musehub commit count:" |
| 62 | muse -C "$MUSEHUB_COPY" rev-list --count HEAD --json |
| 63 | } |
| 64 | |
| 65 | _replay_full_history() { |
| 66 | local muse_copy="$1" git_backup="$2" label="$3" |
| 67 | guard "$muse_copy" |
| 68 | guard "$git_backup" |
| 69 | |
| 70 | mkdir -p "$git_backup" |
| 71 | local already_exported=0 |
| 72 | if [ ! -d "$git_backup/.git" ]; then |
| 73 | git -C "$git_backup" init -q |
| 74 | git -C "$git_backup" config user.email "[email protected]" |
| 75 | git -C "$git_backup" config user.name "gabriel" |
| 76 | git -C "$git_backup" commit --allow-empty -qm "init ($label git-bridge backup mirror)" |
| 77 | else |
| 78 | already_exported=$(git -C "$git_backup" rev-list --count muse-mirror 2>/dev/null || echo 1) |
| 79 | already_exported=$((already_exported - 1)) |
| 80 | [ "$already_exported" -lt 0 ] && already_exported=0 |
| 81 | fi |
| 82 | |
| 83 | local commits total n skip |
| 84 | commits=$(muse -C "$muse_copy" rev-list --reverse HEAD --json | python3 -c "import sys,json; print('\n'.join(json.load(sys.stdin)['commit_ids']))") |
| 85 | |
| 86 | skip="${SKIP:-$already_exported}" |
| 87 | if [ "$skip" -gt 0 ]; then |
| 88 | echo "Skipping first $skip commits already on $label-mirror" |
| 89 | commits=$(echo "$commits" | tail -n "+$((skip + 1))") |
| 90 | fi |
| 91 | |
| 92 | if [ -z "$commits" ]; then |
| 93 | echo "Nothing to export — $label-mirror is already fully replayed." |
| 94 | return 0 |
| 95 | fi |
| 96 | |
| 97 | if [ -n "${LIMIT:-}" ]; then |
| 98 | # head -n via a pipe can SIGPIPE the writer on large input (head exits |
| 99 | # once it has its N lines, closing its read end while echo is still |
| 100 | # blocked writing the rest) -- a here-string avoids the pipe entirely. |
| 101 | # Only surfaced at real scale (1459 commits); tiny test fixtures never |
| 102 | # produced enough output to hit the pipe-buffer boundary. |
| 103 | commits=$(head -n "$LIMIT" <<< "$commits") |
| 104 | echo "LIMIT=$LIMIT set — replaying only $LIMIT commits from this point (dry run)" |
| 105 | fi |
| 106 | |
| 107 | total=$(echo "$commits" | wc -l | tr -d ' ') |
| 108 | n=0 |
| 109 | while IFS= read -r cid; do |
| 110 | n=$((n + 1)) |
| 111 | echo "[$n/$total] exporting $cid" |
| 112 | (cd "$muse_copy" && muse bridge git-export \ |
| 113 | --muse-ref "$cid" \ |
| 114 | --git-dir "$git_backup" \ |
| 115 | --git-branch muse-mirror \ |
| 116 | --no-push \ |
| 117 | --allow-empty \ |
| 118 | --json) |
| 119 | done <<< "$commits" |
| 120 | |
| 121 | echo "git history length:" |
| 122 | git -C "$git_backup" rev-list --count muse-mirror |
| 123 | } |
| 124 | |
| 125 | cmd_replay_muse() { |
| 126 | _replay_full_history "$MUSE_COPY" "$MUSE_GIT_BACKUP" "muse" |
| 127 | } |
| 128 | |
| 129 | cmd_replay_musehub() { |
| 130 | _replay_full_history "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" "musehub" |
| 131 | } |
| 132 | |
| 133 | cmd_replay_all() { |
| 134 | local log_muse log_musehub |
| 135 | log_muse=$(mktemp) |
| 136 | log_musehub=$(mktemp) |
| 137 | echo "Replaying muse and musehub in parallel — logs: $log_muse , $log_musehub" |
| 138 | |
| 139 | ( _replay_full_history "$MUSE_COPY" "$MUSE_GIT_BACKUP" "muse" > "$log_muse" 2>&1 ) & |
| 140 | local pid_muse=$! |
| 141 | ( _replay_full_history "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" "musehub" > "$log_musehub" 2>&1 ) & |
| 142 | local pid_musehub=$! |
| 143 | |
| 144 | wait "$pid_muse" && echo "✅ muse replay finished" || echo "🛑 muse replay FAILED — see $log_muse" |
| 145 | wait "$pid_musehub" && echo "✅ musehub replay finished" || echo "🛑 musehub replay FAILED — see $log_musehub" |
| 146 | |
| 147 | echo "--- muse log ---" |
| 148 | cat "$log_muse" |
| 149 | echo "--- musehub log ---" |
| 150 | cat "$log_musehub" |
| 151 | } |
| 152 | |
| 153 | _github_push_one() { |
| 154 | local git_backup="$1" gh_repo="$2" description="$3" |
| 155 | guard "$git_backup" |
| 156 | |
| 157 | gh auth status || { echo "Run 'gh auth login' first"; exit 1; } |
| 158 | |
| 159 | # public -- muse and musehub are developed in public. |
| 160 | gh repo view "$gh_repo" >/dev/null 2>&1 || gh repo create "$gh_repo" --public --description "$description" |
| 161 | |
| 162 | # https, not [email protected] SSH -- matches `gh auth status`'s reported |
| 163 | # git protocol, which uses gh's own credential helper (no SSH key setup |
| 164 | # required). |
| 165 | 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" |
| 166 | |
| 167 | git -C "$git_backup" push -u origin muse-mirror |
| 168 | } |
| 169 | |
| 170 | cmd_github_push_muse() { |
| 171 | _github_push_one "$MUSE_GIT_BACKUP" "$GH_MUSE_REPO" "Git-bridge backup mirror of the muse Muse repo" |
| 172 | } |
| 173 | |
| 174 | cmd_github_push_musehub() { |
| 175 | _github_push_one "$MUSEHUB_GIT_BACKUP" "$GH_MUSEHUB_REPO" "Git-bridge backup mirror of the musehub Muse repo" |
| 176 | } |
| 177 | |
| 178 | cmd_github_push() { |
| 179 | cmd_github_push_muse |
| 180 | cmd_github_push_musehub |
| 181 | } |
| 182 | |
| 183 | _verify_one() { |
| 184 | local muse_copy="$1" gh_repo="$2" clone_dir="$3" label="$4" |
| 185 | guard "$muse_copy" |
| 186 | |
| 187 | rm -rf "$clone_dir" |
| 188 | git clone "https://github.com/$gh_repo.git" "$clone_dir" |
| 189 | git -C "$clone_dir" checkout muse-mirror |
| 190 | echo "--- $label diff (expect no output) ---" |
| 191 | diff -rq --exclude=.git --exclude=.muse "$muse_copy" "$clone_dir" || true |
| 192 | } |
| 193 | |
| 194 | cmd_verify_muse() { |
| 195 | _verify_one "$MUSE_COPY" "$GH_MUSE_REPO" /tmp/verify-muse-backup muse |
| 196 | } |
| 197 | |
| 198 | cmd_verify_musehub() { |
| 199 | _verify_one "$MUSEHUB_COPY" "$GH_MUSEHUB_REPO" /tmp/verify-musehub-backup musehub |
| 200 | } |
| 201 | |
| 202 | cmd_verify() { |
| 203 | cmd_verify_muse |
| 204 | cmd_verify_musehub |
| 205 | } |
| 206 | |
| 207 | _incremental() { |
| 208 | local muse_copy="$1" git_backup="$2" |
| 209 | local last_bridged new_commits |
| 210 | guard "$muse_copy" |
| 211 | guard "$git_backup" |
| 212 | |
| 213 | last_bridged=$(python3 -c " |
| 214 | import tomllib |
| 215 | with open('$muse_copy/.muse/git-bridge.toml', 'rb') as f: |
| 216 | state = tomllib.load(f) |
| 217 | print(state['last_export']['muse_commit_id']) |
| 218 | ") |
| 219 | echo "Last bridged commit: $last_bridged" |
| 220 | |
| 221 | 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']))") |
| 222 | |
| 223 | if [ -z "$new_commits" ]; then |
| 224 | echo "Nothing new since the last backup — done." |
| 225 | return 0 |
| 226 | fi |
| 227 | |
| 228 | while IFS= read -r cid; do |
| 229 | echo "exporting $cid" |
| 230 | (cd "$muse_copy" && muse bridge git-export \ |
| 231 | --muse-ref "$cid" \ |
| 232 | --git-dir "$git_backup" \ |
| 233 | --git-branch muse-mirror \ |
| 234 | --no-push \ |
| 235 | --allow-empty \ |
| 236 | --json) |
| 237 | done <<< "$new_commits" |
| 238 | |
| 239 | git -C "$git_backup" push origin muse-mirror |
| 240 | } |
| 241 | |
| 242 | cmd_incremental_muse() { |
| 243 | _incremental "$MUSE_COPY" "$MUSE_GIT_BACKUP" |
| 244 | } |
| 245 | |
| 246 | cmd_incremental_musehub() { |
| 247 | _incremental "$MUSEHUB_COPY" "$MUSEHUB_GIT_BACKUP" |
| 248 | } |
| 249 | |
| 250 | SUBCOMMAND="${1:-}" |
| 251 | shift || true |
| 252 | |
| 253 | LIMIT="" |
| 254 | SKIP="" |
| 255 | while [ $# -gt 0 ]; do |
| 256 | case "$1" in |
| 257 | --limit) LIMIT="${2:?--limit requires a number}"; shift 2 ;; |
| 258 | --skip) SKIP="${2:?--skip requires a number}"; shift 2 ;; |
| 259 | *) echo "Unknown flag: $1"; exit 1 ;; |
| 260 | esac |
| 261 | done |
| 262 | |
| 263 | case "$SUBCOMMAND" in |
| 264 | preflight) cmd_preflight ;; |
| 265 | replay-muse) cmd_replay_muse ;; |
| 266 | replay-musehub) cmd_replay_musehub ;; |
| 267 | replay-all) cmd_replay_all ;; |
| 268 | github-push-muse) cmd_github_push_muse ;; |
| 269 | github-push-musehub) cmd_github_push_musehub ;; |
| 270 | github-push) cmd_github_push ;; |
| 271 | verify-muse) cmd_verify_muse ;; |
| 272 | verify-musehub) cmd_verify_musehub ;; |
| 273 | verify) cmd_verify ;; |
| 274 | incremental-muse) cmd_incremental_muse ;; |
| 275 | incremental-musehub) cmd_incremental_musehub ;; |
| 276 | *) |
| 277 | 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]" |
| 278 | exit 1 |
| 279 | ;; |
| 280 | esac |
File History
1 commit
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
9 days ago