#!/usr/bin/env bash # # ============================================================================ # extracttodisk - unpack backup archives back onto the local disk # ============================================================================ # # TLDR # ---- # extracttodisk # newest archive here -> /home/youruser/backuprestore # extracttodisk claude.tar.zst # named archive, Enter accepts the default dir # extracttodisk f1.tar.zst f2.tar.zst # several archives, one after another # extracttodisk *.tar.zst # everything in this folder # extracttodisk notes.txt.tar.zst # file-archives restore to plain files # extracttodisk claude.tar.zst -y # no prompts # extracttodisk claude.tar.zst -l # list contents, extract nothing # extracttodisk -h # this header # # backuptousb claude/ # make an archive (companion script) # # INSTALL # chmod +x ~/scripts/extracttodisk # echo "alias extracttodisk='~/scripts/extracttodisk'" >> ~/.bashrc # source ~/.bashrc # # RAW TAR (what this wraps, if you'd rather type it yourself) # unpack: tar -I zstd -xf claude.tar.zst -C /home/youruser/backuprestore # list: tar -I zstd -tf claude.tar.zst | less # one dir: tar -I zstd -xf claude.tar.zst claude/scripts/ # test: zstd -t claude.tar.zst # gzip: tar -xzf claude.tar.gz | none: tar -xf claude.tar # # ---------------------------------------------------------------------- # # Archives from backuptousb hold RELATIVE paths (claude/...), so extracting # into /home/youruser/backuprestore/ creates /home/youruser/backuprestore/claude/. # Nothing escapes the target dir - your live /home/youruser/claude/ is untouched. # # MANY ARCHIVES: they all land in the SAME restore folder, side by side # (f1.tar.zst -> restore/f1/, f2.tar.zst -> restore/f2/). Asked for the # destination once, up front, then it works through the list unattended. One # archive failing doesn't stop the rest - failures are listed at the end. # # If the restore folder doesn't exist yet, this offers to create it. # # COLLISION PROTECTION: if the target (e.g. apps/) already exists, this lists # exactly which files extracting would OVERWRITE (archive members that match # real files on disk), then lets you choose: [o] overwrite in place, [n] # extract to a fresh apps-restored-2/, or [s] skip. It does NOT try to guess # whether the existing apps/ "is the same folder" - that's not decidable, so # it shows you the facts and you decide. With -y or when piped, it defaults to # the SAFE choice (fresh folder), never a silent overwrite. (-q skips the # index read, so it can only warn, not list collisions - it defaults safe too.) # # Both scripts are copied into the restore folder on every run. # # Prints a live progress bar with an ETA per archive, then a summary: # archive size, restored size, elapsed time, throughput. # # NOTE on big archives: the bar needs a file count, which means reading the # whole archive once before extracting (a 50 GB .zst can take minutes at # "Reading archive index..."). Use -q to skip the count and start extracting # immediately with a spinner instead of a bar. # # ============================================================================ # EDIT ME - defaults # ============================================================================ DEFAULT_RESTORE="/home/youruser/backuprestore" # where archives get unpacked # ============================================================================ # script # ============================================================================ set -euo pipefail die() { printf 'extracttodisk: %s\n' "$1" >&2; exit 1; } ASSUME_YES="no" LIST_ONLY="no" QUICK="no" OUT_OVERRIDE="" ARCHIVES=() while [ $# -gt 0 ]; do case "$1" in -y|--yes) ASSUME_YES="yes"; shift ;; -l|--list) LIST_ONLY="yes"; shift ;; -q|--quick) QUICK="yes"; shift ;; -o|--out) OUT_OVERRIDE="${2:-}"; [ -n "$OUT_OVERRIDE" ] || die "-o needs a path"; shift 2 ;; # Leading comment block: skip shebang, print '#' lines, stop at the first # non-comment. Grows with the header - no line numbers to keep in sync. -h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit 0 ;; -*) die "unknown option: $1" ;; *) ARCHIVES+=("$1"); shift ;; esac done SELF="$(readlink -f "$0")" SELF_DIR="$(dirname "$SELF")" # Nothing named? Take the newest archive sitting next to this script. if [ "${#ARCHIVES[@]}" -eq 0 ]; then newest="$(ls -1t "$SELF_DIR"/*.tar.zst "$SELF_DIR"/*.tar.gz "$SELF_DIR"/*.tar 2>/dev/null | head -1 || true)" [ -n "$newest" ] || die "no archive given and none found in $SELF_DIR usage: extracttodisk [more.tar.zst ...]" ARCHIVES=("$newest") printf 'Using newest archive here: %s\n' "$(basename "$newest")" fi # --- validate every archive BEFORE extracting anything -------------------- # Fail fast on a typo in archive 3 rather than 20 minutes into archive 1. CLEAN=() for a in "${ARCHIVES[@]}"; do # Relative name? Look next to the script before giving up. if [ ! -e "$a" ] && [ -e "$SELF_DIR/$a" ]; then a="$SELF_DIR/$a"; fi [ -f "$a" ] || die "no such archive: $a" case "$a" in *.tar.zst|*.tzst|*.tar.gz|*.tgz|*.tar) ;; *) die "not a recognised archive (want .tar.zst/.tar.gz/.tar): $a" ;; esac CLEAN+=("$(readlink -f "$a")") done # Decoder for a given archive name. Echoes a tar flag word, or nothing. decomp_for() { case "$1" in *.tar.zst|*.tzst) printf 'zstd' ;; *.tar.gz|*.tgz) printf 'gzip' ;; *) printf 'none' ;; esac } for a in "${CLEAN[@]}"; do if [ "$(decomp_for "$a")" = "zstd" ] && ! command -v zstd >/dev/null 2>&1; then die "zstd not installed - can't unpack $(basename "$a")" fi done # --- list mode: show and stop -------------------------------------------- if [ "$LIST_ONLY" = "yes" ]; then for a in "${CLEAN[@]}"; do case "$(decomp_for "$a")" in zstd) D=(-I zstd) ;; gzip) D=(-z) ;; *) D=() ;; esac [ "${#CLEAN[@]}" -gt 1 ] && printf '\n===== %s =====\n' "$(basename "$a")" tar "${D[@]}" -tf "$a" done exit 0 fi # --- destination: asked ONCE --------------------------------------------- SUGGESTED="${OUT_OVERRIDE:-$DEFAULT_RESTORE}" if [ "$ASSUME_YES" = "yes" ] || [ -n "$OUT_OVERRIDE" ] || [ ! -t 0 ]; then DEST="$SUGGESTED" printf 'Restore to: %s\n' "$DEST" else if [ "${#CLEAN[@]}" -gt 1 ]; then printf '\n%s archives to restore:\n' "${#CLEAN[@]}" for a in "${CLEAN[@]}"; do printf ' %s\n' "$(basename "$a")"; done printf '\n' fi read -e -i "$SUGGESTED" -r -p "Restore into (Enter to accept): " DEST DEST="${DEST:-$SUGGESTED}" fi DEST="${DEST%/}" [ -n "$DEST" ] || die "no destination given" # Offer to create it rather than dying. if [ ! -d "$DEST" ]; then printf '\nFolder does not exist: %s\n' "$DEST" # -y, an explicit -o, or a non-tty (piped/scripted) run: just make it. # Prompting with no terminal means read hits EOF and we'd abort having # created nothing, which is the worst of both. if [ "$ASSUME_YES" = "yes" ] || [ -n "$OUT_OVERRIDE" ] || [ ! -t 0 ]; then reply="y" else read -r -p "Create it? [Y/n] " reply reply="${reply:-y}" fi case "$reply" in [yY]*) mkdir -p "$DEST" || die "could not create $DEST" printf 'Created %s\n' "$DEST" ;; *) die "aborted - nothing extracted." ;; esac fi [ -w "$DEST" ] || die "not writable: $DEST" SLOTS=28 RUN_START="$(date +%s)" DONE_COUNT=0 FAIL_LIST=() TOTAL_ARCHIVE_BYTES=0 # CURRENT_IDX: cached archive-index temp file. CURRENT_STAGE: the staging dir # used for fresh-folder extracts. Both are wiped if a Ctrl-C / kill interrupts # us mid-work, so nothing half-done is left behind in /tmp or under $DEST. CURRENT_IDX="" CURRENT_STAGE="" cleanup() { [ -n "$CURRENT_IDX" ] && rm -f "$CURRENT_IDX" [ -n "$CURRENT_STAGE" ] && rm -rf "$CURRENT_STAGE" return 0 } trap cleanup EXIT trap 'cleanup; exit 130' INT TERM # ========================================================================== # extract one archive - returns non-zero on failure, never exits the script # ========================================================================== extract_one() { local ARCHIVE="$1" idx="$2" total="$3" local D=() KIND TOP TOTAL_FILES START_EPOCH TAR_RC ELAPSED local ARCHIVE_BYTES ARCHIVE_H REST_H b reply local IDXFILE="" RENAME_TO="" RENAME_AFTER="" STAGE="" XDEST CLASHES CLASH_N m nsfx newtop leftover local disk_h arc_h arc_age KIND="$(decomp_for "$ARCHIVE")" case "$KIND" in zstd) D=(-I zstd) ;; gzip) D=(-z) ;; *) D=() ;; esac if [ "$total" -gt 1 ]; then printf '\n\033[1m[%s/%s] %s\033[0m\n' "$idx" "$total" "$(basename "$ARCHIVE")" fi # --- read the archive index ONCE ---------------------------------------- # We need the member list for two things: the file count (progress bar) and # the collision check below. Reading a 50 GB .zst takes minutes, so we do it # exactly once and cache to a temp file. -q skips it (spinner, no collision # check) for when you already know the target is empty and want to start now. TOTAL_FILES=0 if [ "$QUICK" != "yes" ]; then IDXFILE="$(mktemp /tmp/extracttodisk-idx.XXXXXX)" CURRENT_IDX="$IDXFILE" # so the trap wipes it if we're killed here printf '\nReading archive index (use -q to skip)... ' tar "${D[@]}" -tf "$ARCHIVE" 2>/dev/null > "$IDXFILE" || true TOTAL_FILES="$(wc -l < "$IDXFILE" || echo 0)" printf '%s files\n' "$TOTAL_FILES" TOP="$(head -1 "$IDXFILE" | cut -d/ -f1 || true)" else # -q: no index, so fall back to peeking just the first member for TOP. TOP="$(tar "${D[@]}" -tf "$ARCHIVE" 2>/dev/null | head -1 | cut -d/ -f1 || true)" fi # --- collision check: what real files would extracting OVERWRITE? -------- # Only meaningful when we have the index. Compares archive members against # what's actually on disk under $DEST - a fact, not a guess about whether # "it's the same folder". You decide with the list in front of you. if [ -n "$TOP" ] && [ -e "$DEST/$TOP" ]; then if [ -n "$IDXFILE" ]; then CLASHES="$(mktemp /tmp/extracttodisk-clash.XXXXXX)" # A member collides if it names an existing regular file under $DEST. # Skip directory entries (trailing /) - those merge harmlessly. while IFS= read -r m; do case "$m" in */) continue ;; esac [ -f "$DEST/$m" ] && printf '%s\n' "$m" done < "$IDXFILE" > "$CLASHES" CLASH_N="$(wc -l < "$CLASHES" || echo 0)" if [ "$CLASH_N" -gt 0 ]; then # Sizes/ages so you can judge: is what's on disk stale vs the archive? disk_h="$(du -sh "$DEST/$TOP" 2>/dev/null | cut -f1 || echo '?')" arc_h="$(numfmt --to=iec "$(stat -c %s "$ARCHIVE" 2>/dev/null || echo 0)" 2>/dev/null || echo '?')" arc_age="$(stat -c %y "$ARCHIVE" 2>/dev/null | cut -d. -f1 | cut -c1-16 || echo '?')" printf '\n%s/%s already exists:\n' "$DEST" "$TOP" printf ' on disk now %8s (%s files would be overwritten)\n' "$disk_h" "$CLASH_N" printf ' incoming archive %8s packed %s\n' "$arc_h" "$arc_age" printf '\nWould OVERWRITE these existing files. First few:\n' head -6 "$CLASHES" | sed 's/^/ /' [ "$CLASH_N" -gt 6 ] && printf ' ... and %s more\n' "$(( CLASH_N - 6 ))" rm -f "$CLASHES" if [ "$ASSUME_YES" != "yes" ] && [ -t 0 ]; then printf '\n [o] overwrite in place\n [n] extract to a fresh %s-restored-N/\n [s] skip this archive\n' "$TOP" read -r -p "Choose [o/N/s]: " reply case "$reply" in [oO]*) ;; # overwrite in place [sS]*) printf 'Skipped %s\n' "$(basename "$ARCHIVE")"; rm -f "$IDXFILE"; CURRENT_IDX=""; return 0 ;; *) RENAME_TO="fresh" ;; # default: safe, fresh dir esac else # -y / non-tty: default to the SAFE choice, never silent overwrite. RENAME_TO="fresh" printf 'Non-interactive: extracting to a fresh folder to avoid overwrite.\n' fi fi else # -q mode: no index, so no collision list. Warn and default to safe. printf '\n%s/%s already exists (-q: no collision list).\n' "$DEST" "$TOP" if [ "$ASSUME_YES" != "yes" ] && [ -t 0 ]; then read -r -p "Overwrite in place? [y/N] " reply case "$reply" in [yY]*) ;; *) RENAME_TO="fresh" ;; esac else RENAME_TO="fresh" fi fi # Pick a fresh, non-colliding target name. We do NOT use tar --transform # for this: its sed-style regex breaks on folder names containing regex # metacharacters (e.g. my[test]), silently failing to rename and extracting # straight onto the existing folder - the very overwrite we're avoiding. # Instead extract normally, then `mv` the top dir aside. RENAME_AFTER holds # the target; the extract runs into $DEST as usual and we rename post-hoc. if [ -n "$RENAME_TO" ]; then nsfx=2 while [ -e "$DEST/${TOP}-restored-${nsfx}" ]; do nsfx=$(( nsfx + 1 )); done newtop="${TOP}-restored-${nsfx}" # But if TOP already exists, extracting into $DEST would merge onto it # before we can move it. So extract into a private staging dir instead, # then move the result to newtop. Staging dir is unique and empty. RENAME_AFTER="$newtop" printf 'Extracting to a fresh folder: %s/\n' "$newtop" fi fi rm -f "$IDXFILE"; CURRENT_IDX="" # index no longer needed [ "$TOTAL_FILES" -lt 1 ] && TOTAL_FILES=0 # Fresh-folder mode: extract into a private staging dir under $DEST, then # rename its top-level entry to the chosen name. Avoids merging onto an # existing $DEST/$TOP and avoids regex transforms entirely. XDEST="$DEST" if [ -n "$RENAME_AFTER" ]; then STAGE="$(mktemp -d "$DEST/.extracttodisk-stage.XXXXXX")" CURRENT_STAGE="$STAGE" # trap cleans a killed staging dir XDEST="$STAGE" fi printf '\nExtracting %s\n -> %s/%s\n\n' "$(basename "$ARCHIVE")" "$DEST" "${RENAME_AFTER:-${TOP:+$TOP/}}" START_EPOCH="$(date +%s)" # Same file-counting bar as backuptousb: tar -v emits one line per member. # Counting members is exact at any file size, unlike byte estimates. # TOTAL_FILES=0 (from -q) switches awk to a spinner with a running count. set +e +o pipefail tar "${D[@]}" -xvf "$ARCHIVE" -C "$XDEST" 2>/dev/null | awk -v total="$TOTAL_FILES" -v slots="$SLOTS" -v start="$START_EPOCH" ' BEGIN { split("| / - \\", spin, " ") } { n++ now = systime(); elapsed = now - start if (total > 0) { pct = int(n * 100 / total); if (pct > 100) pct = 100 if (pct != last_pct || now != last_now) { filled = int(pct * slots / 100) bar = "" for (i = 0; i < slots; i++) bar = bar (i < filled ? "[#]" : "[ ]") if (pct > 2 && elapsed > 2) { eta = int(elapsed * (100 - pct) / pct) printf "\r%s %3d%% %02d:%02d left (%d/%d) ", bar, pct, eta/60, eta%60, n, total > "/dev/stderr" } else { printf "\r%s %3d%% --:-- left (%d/%d) ", bar, pct, n, total > "/dev/stderr" } last_pct = pct; last_now = now } } else if (now != last_now) { # no count available: spinner + running total + elapsed s++ printf "\r %s %d files, %02d:%02d elapsed ", spin[(s % 4) + 1], n, elapsed/60, elapsed%60 > "/dev/stderr" last_now = now } } ' TAR_RC=${PIPESTATUS[0]} set -e -o pipefail ELAPSED=$(( $(date +%s) - START_EPOCH )) if [ "$TOTAL_FILES" -gt 0 ]; then # `local b`: without it this loop's counter leaks out and clobbers the # caller's index - the classic "[29/3]" bug. local FINAL_BAR="" for (( b = 0; b < SLOTS; b++ )); do FINAL_BAR+="[#]"; done printf '\r%s 100%% %02d:%02d taken \n' "$FINAL_BAR" $(( ELAPSED / 60 )) $(( ELAPSED % 60 )) else printf '\r done %02d:%02d taken \n' $(( ELAPSED / 60 )) $(( ELAPSED % 60 )) fi if [ "$TAR_RC" -ne 0 ]; then [ -n "$STAGE" ] && rm -rf "$STAGE"; CURRENT_STAGE="" printf '\nFAILED: %s (tar exit %s)\n' "$(basename "$ARCHIVE")" "$TAR_RC" printf 'If the archive came from a run that ended in "Exiting with failure\n' printf 'status", it is incomplete and this restore cannot be trusted.\n' return 1 fi # Fresh-folder mode: promote the staged top-level entry to the chosen name. # The archive's real top dir is $TOP (still its original name inside STAGE); # move it to $DEST/$RENAME_AFTER, then drop the staging dir. Any stray extra # top-level members (unusual) are moved too so nothing is lost. if [ -n "$RENAME_AFTER" ]; then if [ -e "$STAGE/$TOP" ]; then mv "$STAGE/$TOP" "$DEST/$RENAME_AFTER" fi # sweep up anything else the archive left at top level for leftover in "$STAGE"/* "$STAGE"/.[!.]*; do [ -e "$leftover" ] || continue mv "$leftover" "$DEST/" 2>/dev/null || true done rmdir "$STAGE" 2>/dev/null || rm -rf "$STAGE" CURRENT_STAGE="" TOP="$RENAME_AFTER" # so the summary points at the real restored dir fi printf '\nFlushing to disk... ' sync printf 'done\n' # --- per-archive summary ------------------------------------------------ ARCHIVE_BYTES="$(stat -c %s "$ARCHIVE" 2>/dev/null || echo 0)" ARCHIVE_H="$(numfmt --to=iec "$ARCHIVE_BYTES" 2>/dev/null || echo '?')" REST_H="?" [ -n "$TOP" ] && REST_H="$(du -sh "$DEST/$TOP" 2>/dev/null | cut -f1 || echo '?')" printf '\n' if [ "$TOTAL_FILES" -gt 0 ]; then printf ' Archive %s (%s files)\n' "$ARCHIVE_H" "$TOTAL_FILES" else printf ' Archive %s\n' "$ARCHIVE_H" fi printf ' Restored %s -> %s/%s\n' "$REST_H" "$DEST" "${TOP:-}" if [ "$ELAPSED" -ge 3600 ]; then printf ' Time %dh %dm %ds\n' $(( ELAPSED / 3600 )) $(( ELAPSED % 3600 / 60 )) $(( ELAPSED % 60 )) else printf ' Time %dm %ds\n' $(( ELAPSED / 60 )) $(( ELAPSED % 60 )) fi if [ "$ELAPSED" -gt 0 ] && [ "$ARCHIVE_BYTES" -gt 0 ]; then printf ' Speed %s/s read\n' \ "$(numfmt --to=iec $(( ARCHIVE_BYTES / ELAPSED )) 2>/dev/null || echo '?')" else printf ' Speed (too fast to measure)\n' fi TOTAL_ARCHIVE_BYTES=$(( TOTAL_ARCHIVE_BYTES + ARCHIVE_BYTES )) return 0 } # ========================================================================== # main loop - one archive failing must not stop the rest # ========================================================================== N="${#CLEAN[@]}" idx=0 for a in "${CLEAN[@]}"; do idx=$(( idx + 1 )) if extract_one "$a" "$idx" "$N"; then DONE_COUNT=$(( DONE_COUNT + 1 )) else FAIL_LIST+=("$(basename "$a")") fi done # --- run-level summary (only worth printing for a list) ------------------- if [ "$N" -gt 1 ]; then RUN_ELAPSED=$(( $(date +%s) - RUN_START )) printf '\n========================================\n' printf ' %s of %s archives restored into %s/\n' "$DONE_COUNT" "$N" "$DEST" printf ' %s read in ' "$(numfmt --to=iec "$TOTAL_ARCHIVE_BYTES" 2>/dev/null || echo '?')" if [ "$RUN_ELAPSED" -ge 3600 ]; then printf '%dh %dm %ds\n' $(( RUN_ELAPSED / 3600 )) $(( RUN_ELAPSED % 3600 / 60 )) $(( RUN_ELAPSED % 60 )) else printf '%dm %ds\n' $(( RUN_ELAPSED / 60 )) $(( RUN_ELAPSED % 60 )) fi if [ "${#FAIL_LIST[@]}" -gt 0 ]; then printf '\n FAILED (%s):\n' "${#FAIL_LIST[@]}" for f in "${FAIL_LIST[@]}"; do printf ' %s\n' "$f"; done fi printf '========================================\n' fi # --- drop both scripts next to the restored data -------------------------- sync_script() { # $1 = source path, $2 = destination path local src="$1" dst="$2" [ -e "$src" ] || return 0 if [ ! -e "$dst" ]; then cp -p "$src" "$dst" && chmod +x "$dst" && printf ' + %s\n' "$dst" elif ! cmp -s "$src" "$dst"; then cp -p "$src" "$dst" && chmod +x "$dst" && printf ' ~ %s (updated)\n' "$dst" fi } printf '\nScripts in the restore folder:\n' sync_script "$SELF" "$DEST/extracttodisk" sync_script "$SELF_DIR/backuptousb" "$DEST/backuptousb" # Non-zero exit if anything failed, so `extracttodisk a b && echo ok` behaves. [ "${#FAIL_LIST[@]}" -eq 0 ] || exit 1