Last active
May 30, 2025 09:26
-
-
Save mpr1255/2d150f03ceaad0860ea1bf73f0f737c9 to your computer and use it in GitHub Desktop.
Auto-backup script for protection against AI code editors
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# save-my-bacon.sh - Auto-backup with Git shadow repository | |
# | |
# Monitor a directory and automatically commit all changes to a temporary Git repo. | |
# Perfect for protecting against accidental deletions or overwrites by AI editors. | |
# | |
# Usage: save-my-bacon [directory] | |
# Monitors current directory if no argument provided | |
# | |
# Features: | |
# - Automatic commits on every file change | |
# - Easy restore of deleted files | |
# - Browse and restore any file from any point in time | |
# - No pollution of your actual Git repository | |
# - Temporary shadow repo that you can delete when done | |
# | |
# WARNING: This script bypasses ALL git hooks (pre-commit, etc.) in the shadow repo | |
# to ensure all files are backed up regardless of size or content. | |
# Your main repository's hooks remain unaffected. | |
# | |
# Commands while running: | |
# - (l)og: Show recent commits | |
# - (r)estore deleted: Restore deleted files | |
# - (b)rowse history: Find and restore any file from history | |
# - (d)iff: Show recent changes | |
# - (q)uit: Stop monitoring | |
# | |
# Author: Created for protection against AI code editors | |
# License: MIT | |
# Detect OS | |
detect_os() { | |
# Check if we're running in bash | |
if [ -z "$BASH_VERSION" ]; then | |
echo "β Error: This script requires bash, but you're running with: $0" | |
echo " Try: curl -sSL <url> | bash -s -- <args>" | |
echo " Or: bash <script> <args>" | |
exit 1 | |
fi | |
case "$OSTYPE" in | |
linux-gnu*) echo "linux" ;; | |
darwin*) echo "macos" ;; | |
*) | |
# Fallback detection if OSTYPE isn't set | |
if [ -f /etc/os-release ]; then | |
echo "linux" | |
elif [ "$(uname)" = "Darwin" ]; then | |
echo "macos" | |
else | |
echo "unknown" | |
fi | |
;; | |
esac | |
} | |
OS=$(detect_os) | |
# Check for required commands | |
check_command() { | |
if command -v "$1" &> /dev/null; then | |
echo "β $1 found at: $(which $1)" | |
return 0 | |
else | |
echo "β Error: $1 is not installed" | |
return 1 | |
fi | |
} | |
# Show installation instructions based on OS | |
show_install_instructions() { | |
local cmd="$1" | |
echo -n " Install with: " | |
if [[ "$OS" == "macos" ]]; then | |
echo "brew install $cmd" | |
elif [[ "$OS" == "linux" ]]; then | |
case "$cmd" in | |
fswatch) | |
echo "sudo apt-get install fswatch # Debian/Ubuntu" | |
echo " sudo yum install fswatch # RHEL/CentOS" | |
echo " sudo pacman -S fswatch # Arch" | |
;; | |
inotify-tools) | |
echo "sudo apt-get install inotify-tools # Debian/Ubuntu" | |
echo " sudo yum install inotify-tools # RHEL/CentOS" | |
echo " sudo pacman -S inotify-tools # Arch" | |
;; | |
rsync) | |
echo "sudo apt-get install rsync # Debian/Ubuntu" | |
echo " sudo yum install rsync # RHEL/CentOS" | |
echo " sudo pacman -S rsync # Arch" | |
;; | |
git) | |
echo "sudo apt-get install git # Debian/Ubuntu" | |
echo " sudo yum install git # RHEL/CentOS" | |
echo " sudo pacman -S git # Arch" | |
;; | |
tig) | |
echo "sudo apt-get install tig # Debian/Ubuntu" | |
echo " sudo yum install tig # RHEL/CentOS" | |
echo " sudo pacman -S tig # Arch" | |
;; | |
esac | |
fi | |
} | |
# Check all requirements | |
MISSING_DEPS=0 | |
echo "π Checking dependencies for $OS..." | |
# Check file watching tool | |
if [[ "$OS" == "macos" ]]; then | |
if ! check_command "fswatch"; then | |
show_install_instructions "fswatch" | |
MISSING_DEPS=1 | |
fi | |
WATCH_CMD="fswatch" | |
elif [[ "$OS" == "linux" ]]; then | |
# Try fswatch first, then inotify-tools | |
if command -v "fswatch" &> /dev/null; then | |
echo "β fswatch found at: $(which fswatch)" | |
WATCH_CMD="fswatch" | |
elif command -v "inotifywait" &> /dev/null; then | |
echo "β inotifywait found at: $(which inotifywait)" | |
WATCH_CMD="inotifywait" | |
else | |
echo "β Error: No file watching tool found (need fswatch or inotify-tools)" | |
show_install_instructions "fswatch" | |
echo " OR" | |
show_install_instructions "inotify-tools" | |
MISSING_DEPS=1 | |
fi | |
else | |
echo "β Error: Unsupported OS: $OSTYPE" | |
exit 1 | |
fi | |
if ! check_command "rsync"; then | |
show_install_instructions "rsync" | |
MISSING_DEPS=1 | |
fi | |
if ! check_command "git"; then | |
show_install_instructions "git" | |
MISSING_DEPS=1 | |
fi | |
# Optional: check for tig (git TUI) | |
if ! command -v "tig" &> /dev/null; then | |
echo "β οΈ Optional: tig is not installed (for visual git browsing)" | |
show_install_instructions "tig" | |
fi | |
if [[ $MISSING_DEPS -eq 1 ]]; then | |
echo "" | |
echo "β Missing required dependencies. Please install them and try again." | |
exit 1 | |
fi | |
echo "β All required dependencies installed" | |
echo "" | |
# Show help if no args | |
if [[ $# -eq 0 ]]; then | |
echo "π save-my-bacon - Auto-backup for protection against AI editors" | |
echo "" | |
echo "Usage:" | |
echo " save-my-bacon <directory> Monitor directory for changes" | |
echo " save-my-bacon . Monitor current directory" | |
echo " save-my-bacon --explore Browse recent shadow repos" | |
echo " save-my-bacon -e <path> Explore specific shadow repo" | |
echo "" | |
echo "Examples:" | |
echo " save-my-bacon ~/projects/myapp" | |
echo " save-my-bacon /tmp/working-dir" | |
echo " cd ~/projects/myapp && save-my-bacon ." | |
echo "" | |
echo "Shadow repos are saved as: /tmp/<project-name>-shadow-<timestamp>" | |
echo "" | |
echo "To run directly from URL (IMPORTANT: use 'bash', not 'sh'):" | |
echo " curl -sSL <url> | bash -s -- <directory>" | |
echo "" | |
exit 0 | |
fi | |
# Handle different modes | |
if [[ "$1" == "--explore" ]] || [[ "$1" == "-e" ]]; then | |
# Explore mode - browse existing shadow repos | |
if [[ "$2" ]]; then | |
# Specific shadow provided | |
SHADOW="$2" | |
else | |
echo "π Recent shadow repositories:" | |
ls -ltd /tmp/*-shadow-* 2>/dev/null | head -15 | nl -w3 -s'. ' | |
echo -n "Enter number to explore (or path to specific shadow): " | |
read -r choice < /dev/tty | |
if [[ "$choice" =~ ^[0-9]+$ ]]; then | |
SHADOW=$(ls -ltd /tmp/*-shadow-* 2>/dev/null | sed -n "${choice}p" | awk '{print $9}') | |
else | |
SHADOW="$choice" | |
fi | |
fi | |
if [[ ! -d "$SHADOW/.git" ]]; then | |
echo "β Not a valid shadow repository: $SHADOW" | |
exit 1 | |
fi | |
cd "$SHADOW" | |
TARGET="[Exploration Mode - No Active Monitoring]" | |
echo "π Exploring shadow: $SHADOW" | |
echo "" | |
else | |
# Normal monitoring mode | |
TARGET="${1:-.}" | |
# Resolve real path and get project name | |
TARGET="$(cd "$TARGET" && pwd)" | |
PROJECT_NAME=$(basename "$TARGET") | |
# Check for existing shadows for this project | |
EXISTING_SHADOWS=$(ls -dt /tmp/${PROJECT_NAME}-shadow-* 2>/dev/null | head -5) | |
if [[ -n "$EXISTING_SHADOWS" ]]; then | |
echo "π Found existing shadows for '$PROJECT_NAME':" | |
echo "$EXISTING_SHADOWS" | nl -w3 -s'. ' | |
echo "" | |
echo "Options:" | |
echo " Enter number 1-5 to resume that shadow" | |
echo " Press 'n' to create new shadow" | |
echo " Press 'q' to quit" | |
echo -n "Choice: " | |
read -r choice < /dev/tty | |
if [[ "$choice" == "q" ]]; then | |
exit 0 | |
elif [[ "$choice" == "n" ]]; then | |
# Create new shadow | |
SHADOW="/tmp/${PROJECT_NAME}-shadow-$(date +%s)" | |
elif [[ "$choice" =~ ^[1-5]$ ]]; then | |
# Resume existing shadow | |
SHADOW=$(echo "$EXISTING_SHADOWS" | sed -n "${choice}p") | |
if [[ -d "$SHADOW/.git" ]]; then | |
cd "$SHADOW" | |
echo "β Resumed shadow: $SHADOW" | |
echo "" | |
# Show recent activity | |
echo "π Recent commits in this shadow:" | |
git log --oneline -5 --pretty=format:"%h %ad %s" --date=format:"%m/%d %H:%M" || echo "No commits yet" | |
echo "" | |
else | |
echo "β Invalid shadow repository" | |
exit 1 | |
fi | |
else | |
echo "β Invalid choice" | |
exit 1 | |
fi | |
else | |
# No existing shadows, create new one | |
SHADOW="/tmp/${PROJECT_NAME}-shadow-$(date +%s)" | |
fi | |
# Only do initial setup if we're not resuming | |
if [[ ! -d "$SHADOW/.git" ]]; then | |
echo "π‘οΈ Monitoring: $TARGET" | |
echo "π Shadow: $SHADOW" | |
echo "π‘ Tip: Use 'save-my-bacon --explore' later to browse shadows" | |
echo "" | |
# Create shadow and copy EVERYTHING (including .git if it exists) | |
mkdir -p "$SHADOW" | |
cd "$SHADOW" | |
git init -q | |
# Initial copy - get EVERYTHING first | |
cp -r "$TARGET"/* "$SHADOW"/ 2>/dev/null | |
cp -r "$TARGET"/.[^.]* "$SHADOW"/ 2>/dev/null # Hidden files too | |
# Remove the .git from shadow if it exists | |
rm -rf "$SHADOW/.git" 2>/dev/null | |
# Re-init our shadow git (disable any global hooks) | |
git init -q | |
git config core.hooksPath /dev/null | |
git add -A | |
git commit -m "Initial snapshot of $PROJECT_NAME" -q --no-verify | |
echo "β Initial commit done" | |
fi | |
# Start monitoring (if not in explore mode) | |
echo "π― Starting file monitor (using $WATCH_CMD)..." | |
# Different monitoring based on available tool | |
if [[ "$WATCH_CMD" == "fswatch" ]]; then | |
# fswatch is available on both macOS and Linux | |
( | |
while true; do | |
# Use fswatch in batch mode - collect changes for 1 second | |
fswatch -r -1 --batch-marker "$TARGET" -e "\.git" -e "\.save-my-bacon-shadow" | while read line; do | |
if [[ "$line" == "NoOp" ]]; then | |
# Batch of changes complete, do a commit | |
cd "$SHADOW" | |
rsync -a --delete "$TARGET/" "$SHADOW/" --exclude='.git' --exclude='.save-my-bacon-shadow' | |
if git add -A && ! git diff --cached --quiet; then | |
# Get list of changed files for commit message | |
CHANGED=$(git diff --cached --name-status | head -5 | awk '{print $2}' | xargs basename -a 2>/dev/null | tr '\n' ',' | sed 's/,$//') | |
if [[ $(git diff --cached --name-status | wc -l) -gt 5 ]]; then | |
CHANGED="${CHANGED}..." | |
fi | |
git commit -m "$(date +%H:%M:%S) - Changed: $CHANGED" -q --no-verify | |
echo -ne "\rπΈ Snapshot saved: $(date +%H:%M:%S) - $CHANGED " | |
fi | |
fi | |
done | |
# Restart fswatch if it exits | |
sleep 1 | |
done | |
) & | |
WATCH_PID=$! | |
else | |
# inotifywait (Linux only) | |
( | |
while true; do | |
# Monitor for changes | |
inotifywait -r -e modify,create,delete,move --format '%w%f' "$TARGET" --exclude '\.git|\.save-my-bacon-shadow' 2>/dev/null | while read file; do | |
# Debounce - collect changes for a short time | |
sleep 0.5 | |
cd "$SHADOW" | |
rsync -a --delete "$TARGET/" "$SHADOW/" --exclude='.git' --exclude='.save-my-bacon-shadow' | |
if git add -A && ! git diff --cached --quiet; then | |
# Get list of changed files for commit message | |
CHANGED=$(git diff --cached --name-status | head -5 | awk '{print $2}' | xargs basename -a 2>/dev/null | tr '\n' ',' | sed 's/,$//') | |
if [[ $(git diff --cached --name-status | wc -l) -gt 5 ]]; then | |
CHANGED="${CHANGED}..." | |
fi | |
git commit -m "$(date +%H:%M:%S) - Changed: $CHANGED" -q --no-verify | |
echo -ne "\rπΈ Snapshot saved: $(date +%H:%M:%S) - $CHANGED " | |
fi | |
done | |
# Restart inotifywait if it exits | |
sleep 1 | |
done | |
) & | |
WATCH_PID=$! | |
fi | |
trap "kill $WATCH_PID 2>/dev/null; echo -e '\nπ Stopped. Shadow at: $SHADOW'" EXIT | |
fi | |
# Restore function | |
restore_file() { | |
echo "π Finding deleted files..." | |
# Show deleted files | |
DELETED_FILES=$(git log --diff-filter=D --name-only --pretty=format: | sort -u | grep -v '^$') | |
if [[ -z "$DELETED_FILES" ]]; then | |
echo "No deleted files found" | |
return | |
fi | |
echo "Deleted files:" | |
echo "$DELETED_FILES" | nl -w3 -s'. ' | |
echo -n "Enter file number to restore (or 'q' to cancel): " | |
read -r choice < /dev/tty | |
if [[ "$choice" == "q" ]]; then | |
return | |
fi | |
FILE_TO_RESTORE=$(echo "$DELETED_FILES" | sed -n "${choice}p") | |
if [[ -z "$FILE_TO_RESTORE" ]]; then | |
echo "Invalid selection" | |
return | |
fi | |
# Find last commit where file existed | |
LAST_COMMIT=$(git log --diff-filter=D --name-only --pretty=format:"%H" -- "$FILE_TO_RESTORE" | head -1) | |
if [[ -z "$LAST_COMMIT" ]]; then | |
echo "Could not find deletion commit" | |
return | |
fi | |
# Get commit before deletion | |
RESTORE_COMMIT=$(git log --pretty=format:"%H" -n 1 "$LAST_COMMIT^" -- "$FILE_TO_RESTORE" 2>/dev/null) | |
if [[ -z "$RESTORE_COMMIT" ]]; then | |
echo "Could not find file in history" | |
return | |
fi | |
# Show file content | |
echo -e "\nπ File content from $(git log -1 --pretty=format:"%ad" --date=format:"%H:%M:%S" "$RESTORE_COMMIT"):" | |
git show "$RESTORE_COMMIT:$FILE_TO_RESTORE" | head -20 | |
echo "..." | |
echo -n "Restore this file to $TARGET/$FILE_TO_RESTORE? (y/n): " | |
read -r confirm < /dev/tty | |
if [[ "$confirm" == "y" ]]; then | |
git show "$RESTORE_COMMIT:$FILE_TO_RESTORE" > "$TARGET/$FILE_TO_RESTORE" | |
echo "β Restored $FILE_TO_RESTORE" | |
fi | |
} | |
# Browse and restore any file from history | |
browse_history() { | |
echo "π Browse file history" | |
echo -n "Enter filename (or part of it): " | |
read -r search < /dev/tty | |
# Find all commits that have files matching the search | |
echo "Searching for files matching '$search'..." | |
# Get all commits with their dates | |
COMMITS=$(git log --pretty=format:"%H %ad" --date=format:"%Y-%m-%d %H:%M:%S" --all) | |
# Find files matching search in each commit | |
MATCHING_COMMITS="" | |
while IFS= read -r commit_info; do | |
HASH=$(echo "$commit_info" | awk '{print $1}') | |
DATE=$(echo "$commit_info" | cut -d' ' -f2-) | |
# Check if this commit has files matching our search | |
if git ls-tree -r --name-only "$HASH" 2>/dev/null | grep -q "$search"; then | |
# Get the commit message | |
MSG=$(git log -1 --pretty=format:"%s" "$HASH") | |
MATCHING_COMMITS="${MATCHING_COMMITS}${HASH} ${DATE} ${MSG}\n" | |
fi | |
done <<< "$COMMITS" | |
if [[ -z "$MATCHING_COMMITS" ]]; then | |
echo "No files found matching '$search'" | |
return | |
fi | |
echo -e "\nFound in these snapshots:" | |
echo -e "$MATCHING_COMMITS" | nl -w3 -s'. ' | |
echo -n "Enter snapshot number to explore (or 'q' to cancel): " | |
read -r choice < /dev/tty | |
if [[ "$choice" == "q" ]]; then | |
return | |
fi | |
SELECTED=$(echo -e "$MATCHING_COMMITS" | sed -n "${choice}p") | |
COMMIT_HASH=$(echo "$SELECTED" | awk '{print $1}') | |
# Show matching files in that commit | |
echo -e "\nFiles matching '$search' in this snapshot:" | |
git ls-tree -r --name-only "$COMMIT_HASH" | grep "$search" | nl -w3 -s'. ' | |
echo -n "Enter file number to view/restore: " | |
read -r file_choice < /dev/tty | |
FILE_PATH=$(git ls-tree -r --name-only "$COMMIT_HASH" | grep "$search" | sed -n "${file_choice}p") | |
if [[ -z "$FILE_PATH" ]]; then | |
echo "Invalid selection" | |
return | |
fi | |
# Show file size/preview (with better size handling) | |
FILE_SIZE=$(git cat-file -s "$COMMIT_HASH:$FILE_PATH" 2>/dev/null || echo "0") | |
# Format file size nicely | |
if command -v numfmt &> /dev/null; then | |
SIZE_STR=$(numfmt --to=iec-i --suffix=B $FILE_SIZE) | |
else | |
# Fallback for systems without numfmt | |
if [[ $FILE_SIZE -lt 1024 ]]; then | |
SIZE_STR="${FILE_SIZE}B" | |
elif [[ $FILE_SIZE -lt 1048576 ]]; then | |
SIZE_STR="$((FILE_SIZE / 1024))KiB" | |
else | |
SIZE_STR="$((FILE_SIZE / 1048576))MiB" | |
fi | |
fi | |
echo -e "\nπ File: $FILE_PATH ($SIZE_STR)" | |
echo "From snapshot: $(echo "$SELECTED" | cut -d' ' -f2-)" | |
echo -e "\nPreview:" | |
git show "$COMMIT_HASH:$FILE_PATH" | head -20 | |
if [[ $(git show "$COMMIT_HASH:$FILE_PATH" | wc -l) -gt 20 ]]; then | |
echo "... (truncated, file has $(git show "$COMMIT_HASH:$FILE_PATH" | wc -l) lines)" | |
fi | |
echo -e "\nOptions:" | |
echo " (r) Restore this file" | |
echo " (v) View full file" | |
echo " (d) Compare with current version" | |
echo " (q) Cancel" | |
echo -n "Choice: " | |
read -n 1 -r action < /dev/tty | |
echo | |
case $action in | |
r) | |
mkdir -p "$TARGET/$(dirname "$FILE_PATH")" | |
git show "$COMMIT_HASH:$FILE_PATH" > "$TARGET/$FILE_PATH" | |
echo "β Restored $FILE_PATH from $(echo "$SELECTED" | cut -d' ' -f2-3)" | |
;; | |
v) | |
git show "$COMMIT_HASH:$FILE_PATH" | ${PAGER:-less} | |
;; | |
d) | |
if [[ -f "$TARGET/$FILE_PATH" ]]; then | |
echo "Comparing with current version:" | |
git show "$COMMIT_HASH:$FILE_PATH" | diff -u "$TARGET/$FILE_PATH" - || true | |
else | |
echo "Current file doesn't exist" | |
fi | |
;; | |
*) | |
echo "Cancelled" | |
;; | |
esac | |
} | |
echo "Commands: (l)og, (r)estore deleted, (b)rowse history, (d)iff, (t)ig, (q)uit" | |
while true; do | |
read -n 1 -s -p "> " cmd < /dev/tty | |
echo | |
case $cmd in | |
l) git log --oneline -10 --pretty=format:"%h %ad %s" --date=format:"%H:%M:%S" || echo "No commits yet" ;; | |
r) restore_file ;; | |
b) browse_history ;; | |
d) git diff HEAD~1 2>/dev/null || echo "No previous commits" ;; | |
t) which tig >/dev/null && tig || echo "tig not installed" ;; | |
q) break ;; | |
esac | |
done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment