Skip to content

Instantly share code, notes, and snippets.

@mpr1255
Last active May 30, 2025 09:26
Show Gist options
  • Save mpr1255/2d150f03ceaad0860ea1bf73f0f737c9 to your computer and use it in GitHub Desktop.
Save mpr1255/2d150f03ceaad0860ea1bf73f0f737c9 to your computer and use it in GitHub Desktop.
Auto-backup script for protection against AI code editors
#!/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