Skip to content

Instantly share code, notes, and snippets.

@thoroc
Created February 4, 2026 10:51
Show Gist options
  • Select an option

  • Save thoroc/725fb24537dde3b6927a797d6be04d45 to your computer and use it in GitHub Desktop.

Select an option

Save thoroc/725fb24537dde3b6927a797d6be04d45 to your computer and use it in GitHub Desktop.
restore file from history
#!/usr/bin/env zsh
# Script to find and restore files/directories from git history
# Usage: ./restore-file-from-history.sh [OPTIONS] <pattern>
# Supports partial filenames and directories
#
# Options:
# -a, --all Automatically select all matches
# -y, --yes Automatically confirm restoration
# -h, --help Show this help message
set -e
# Disable git pager to avoid delta/pager errors
export GIT_PAGER=cat
# Dynamically find command locations and export them
export GIT=$(which git 2>/dev/null || echo /usr/bin/git)
export SED=$(which sed 2>/dev/null || echo /usr/bin/sed)
export GREP=$(which grep 2>/dev/null || echo /usr/bin/grep)
export SORT=$(which sort 2>/dev/null || echo /usr/bin/sort)
export UNIQ=$(which uniq 2>/dev/null || echo /usr/bin/uniq)
export AWK=$(which awk 2>/dev/null || echo /usr/bin/awk)
export MKTEMP=$(which mktemp 2>/dev/null || echo /usr/bin/mktemp)
export RM=$(which rm 2>/dev/null || echo /bin/rm)
export WC=$(which wc 2>/dev/null || echo /usr/bin/wc)
export TR=$(which tr 2>/dev/null || echo /usr/bin/tr)
export HEAD=$(which head 2>/dev/null || echo /usr/bin/head)
export CUT=$(which cut 2>/dev/null || echo /usr/bin/cut)
export MKDIR=$(which mkdir 2>/dev/null || echo /bin/mkdir)
export DIRNAME=$(which dirname 2>/dev/null || echo /usr/bin/dirname)
export BASENAME=$(which basename 2>/dev/null || echo /usr/bin/basename)
# Get script name for help text
SCRIPT_NAME=$($BASENAME "$0")
# Parse command line arguments
AUTO_ALL=false
AUTO_YES=false
PATTERN=""
show_help() {
cat << EOF
Usage: $SCRIPT_NAME [OPTIONS] <pattern>
Find and restore files/directories from git history
Options:
-a, --all Automatically select all matches
-y, --yes Automatically confirm restoration
-h, --help Show this help message
Examples:
$SCRIPT_NAME config # Search for 'config'
$SCRIPT_NAME --all --yes bdd-patterns # Restore all matches without prompts
$SCRIPT_NAME -a -y src/utils # Short form
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case $1 in
-a|--all)
AUTO_ALL=true
shift
;;
-y|--yes)
AUTO_YES=true
shift
;;
-h|--help)
show_help
;;
-*)
echo "Error: Unknown option $1"
echo "Use --help for usage information"
exit 1
;;
*)
PATTERN="$1"
shift
;;
esac
done
if [ -z "$PATTERN" ]; then
echo "Error: Pattern required"
echo "Usage: $0 [OPTIONS] <pattern>"
echo "Use --help for more information"
exit 1
fi
echo "Searching for files/directories matching '*$PATTERN*' in git history..."
echo
# Find all unique paths that match the pattern
MATCHING_PATHS=$($GIT log --all --pretty=format: --name-only | $GREP -i "$PATTERN" | $SORT -u)
if [ -z "$MATCHING_PATHS" ]; then
echo "Error: No paths matching '*$PATTERN*' found in git history"
exit 1
fi
# Detect potential directories by finding common path prefixes
POTENTIAL_DIRS=$(echo "$MATCHING_PATHS" | $SED 's|/[^/]*$||' | $SORT -u | $UNIQ -c | $AWK '$1 > 1 {print $2}')
# Build a list of options: directories first, then individual files
OPTIONS_FILE=$($MKTEMP)
trap '$RM -f "$OPTIONS_FILE"' EXIT
if [ -n "$POTENTIAL_DIRS" ]; then
# Add directories to options
echo "$POTENTIAL_DIRS" | while read -r dir; do
file_count=$(echo "$MATCHING_PATHS" | $GREP -c "^${dir}/" || true)
if [ "$file_count" -gt 0 ]; then
echo "DIR|$dir|$file_count" >> "$OPTIONS_FILE"
fi
done
fi
# Add individual files that don't belong to detected directories
echo "$MATCHING_PATHS" | while read -r path; do
DIR_PART=$(echo "$path" | $SED 's|/[^/]*$||')
if ! echo "$POTENTIAL_DIRS" | $GREP -q "^${DIR_PART}$"; then
echo "FILE|$path" >> "$OPTIONS_FILE"
fi
done
# If no options were built, fall back to treating everything as individual files
if [ ! -s "$OPTIONS_FILE" ]; then
echo "$MATCHING_PATHS" | while read -r path; do
echo "FILE|$path" >> "$OPTIONS_FILE"
done
fi
# Count total options
OPTION_COUNT=$($WC -l < "$OPTIONS_FILE" | $TR -d ' ')
if [ "$OPTION_COUNT" -eq 0 ]; then
echo "Error: No valid paths found"
exit 1
fi
if [ "$OPTION_COUNT" -gt 1 ]; then
echo "Found $OPTION_COUNT matches:"
echo "-----------------------------------"
i=1
while IFS='|' read -r type path count; do
if [ "$type" = "DIR" ]; then
echo "$i. $path/ (directory with $count files)"
else
echo "$i. $path"
fi
i=$((i + 1))
done < "$OPTIONS_FILE"
if [ "$AUTO_ALL" = false ]; then
echo "a. All matches"
echo
echo "Enter the number to restore (1-$OPTION_COUNT, or 'a' for all): "
read SELECTION
else
SELECTION="a"
echo
echo "Auto-selecting: All matches (--all flag)"
fi
# Check if user wants to restore all
if [[ "$SELECTION" =~ ^[Aa]$ ]]; then
RESTORE_ALL=true
IS_DIRECTORY=false
elif echo "$SELECTION" | $GREP -qE '^[0-9]+$' && [ "$SELECTION" -ge 1 ] && [ "$SELECTION" -le "$OPTION_COUNT" ]; then
RESTORE_ALL=false
SELECTED_LINE=$($SED -n "${SELECTION}p" "$OPTIONS_FILE")
SELECTED_TYPE=$(echo "$SELECTED_LINE" | $CUT -d'|' -f1)
RESTORE_PATH=$(echo "$SELECTED_LINE" | $CUT -d'|' -f2)
if [ "$SELECTED_TYPE" = "DIR" ]; then
IS_DIRECTORY=true
else
IS_DIRECTORY=false
fi
else
echo "Error: Invalid selection"
exit 1
fi
else
# Single match - check if it's a directory
RESTORE_ALL=false
FIRST_LINE=$($HEAD -1 "$OPTIONS_FILE")
SELECTED_TYPE=$(echo "$FIRST_LINE" | $CUT -d'|' -f1)
RESTORE_PATH=$(echo "$FIRST_LINE" | $CUT -d'|' -f2)
if [ "$SELECTED_TYPE" = "DIR" ]; then
IS_DIRECTORY=true
echo "Detected directory: $RESTORE_PATH/"
else
IS_DIRECTORY=false
fi
fi
echo
if [ "$RESTORE_ALL" = true ]; then
# Restoring all matches
FILES_TO_RESTORE=""
while IFS='|' read -r type path count; do
if [ "$type" = "DIR" ]; then
# Add all files in directory
DIR_FILES=$(echo "$MATCHING_PATHS" | $GREP "^$path/" || echo "$MATCHING_PATHS" | $GREP "$path")
FILES_TO_RESTORE="${FILES_TO_RESTORE}${DIR_FILES}"$'\n'
else
# Add individual file
FILES_TO_RESTORE="${FILES_TO_RESTORE}${path}"$'\n'
fi
done < "$OPTIONS_FILE"
FILES_TO_RESTORE=$(echo "$FILES_TO_RESTORE" | $GREP -v '^$') # Remove empty lines
FILE_COUNT=$(echo "$FILES_TO_RESTORE" | $WC -l | $TR -d ' ')
echo "Selected: All $OPTION_COUNT matches ($FILE_COUNT total files)"
elif [ "$IS_DIRECTORY" = true ]; then
# Find all files in this directory from history
FILES_TO_RESTORE=$(echo "$MATCHING_PATHS" | $GREP "^$RESTORE_PATH/" || echo "$MATCHING_PATHS" | $GREP "$RESTORE_PATH")
FILE_COUNT=$(echo "$FILES_TO_RESTORE" | $WC -l | $TR -d ' ')
echo "Selected directory: $RESTORE_PATH/ ($FILE_COUNT files)"
else
FILES_TO_RESTORE="$RESTORE_PATH"
echo "Selected file: $FILES_TO_RESTORE"
fi
echo
# Check which files already exist
EXISTING_FILES=""
MISSING_FILES=""
while IFS= read -r file; do
if [ -f "$file" ]; then
EXISTING_FILES="${EXISTING_FILES}${file}"$'\n'
else
MISSING_FILES="${MISSING_FILES}${file}"$'\n'
fi
done <<< "$FILES_TO_RESTORE"
# Clean up empty lines
EXISTING_FILES=$(echo "$EXISTING_FILES" | $GREP -v '^$' || true)
MISSING_FILES=$(echo "$MISSING_FILES" | $GREP -v '^$' || true)
# Count files
if [ -n "$EXISTING_FILES" ]; then
EXISTING_COUNT=$(echo "$EXISTING_FILES" | $WC -l | $TR -d ' ')
else
EXISTING_COUNT=0
fi
if [ -n "$MISSING_FILES" ]; then
MISSING_COUNT=$(echo "$MISSING_FILES" | $WC -l | $TR -d ' ')
else
MISSING_COUNT=0
fi
# Inform user about existing files
if [ "$EXISTING_COUNT" -gt 0 ]; then
echo "⚠️ Skipping $EXISTING_COUNT file(s) that already exist:"
echo "$EXISTING_FILES" | while read -r f; do echo " - $f"; done
echo
fi
# Check if there are any files to restore
if [ "$MISSING_COUNT" -eq 0 ]; then
echo "✓ All files already exist. Nothing to restore."
exit 0
fi
echo "Files to restore ($MISSING_COUNT):"
echo "$MISSING_FILES" | $HEAD -20
[ "$MISSING_COUNT" -gt 20 ] && echo "... and $((MISSING_COUNT - 20)) more"
echo
# Ask for confirmation
if [ "$AUTO_YES" = false ]; then
if [ "$RESTORE_ALL" = true ]; then
echo "Restore $MISSING_COUNT file(s)? (y/n)"
read REPLY
elif [ "$IS_DIRECTORY" = true ]; then
echo "Restore directory '$RESTORE_PATH/' with $MISSING_COUNT file(s)? (y/n)"
read REPLY
else
# Single file - get its commit info
COMMITS=$($GIT log --all --pretty=format:"%H" -- "$MISSING_FILES" 2>/dev/null | $HEAD -1)
if [ -z "$COMMITS" ]; then
echo "Error: Could not find commits for '$MISSING_FILES'"
exit 1
fi
echo "Latest commit: $COMMITS"
$GIT log --oneline -1 "$COMMITS"
echo
echo "Restore '$MISSING_FILES' from commit $COMMITS? (y/n)"
read REPLY
fi
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled"
exit 0
fi
else
# Auto-confirm with --yes flag
if [ "$RESTORE_ALL" = true ]; then
echo "Auto-confirming: Restore $MISSING_COUNT file(s) (--yes flag)"
elif [ "$IS_DIRECTORY" = true ]; then
echo "Auto-confirming: Restore directory '$RESTORE_PATH/' with $MISSING_COUNT file(s) (--yes flag)"
else
COMMITS=$($GIT log --all --pretty=format:"%H" -- "$MISSING_FILES" 2>/dev/null | $HEAD -1)
if [ -z "$COMMITS" ]; then
echo "Error: Could not find commits for '$MISSING_FILES'"
exit 1
fi
echo "Auto-confirming: Restore '$MISSING_FILES' from commit $COMMITS (--yes flag)"
fi
echo
fi
# Restore the file(s) - each from its own most recent commit
echo "Restoring files..."
RESTORED_COUNT=0
FAILED_COUNT=0
while IFS= read -r file; do
# Find the most recent commit where this file actually exists (not just mentioned)
FILE_COMMIT=$($GIT log --all --pretty=format:"%H" --follow -- "$file" 2>/dev/null | while read commit; do
# Check if file exists at this path in this commit
if $GIT ls-tree -r "$commit" --name-only 2>/dev/null | $GREP -q "^${file}$"; then
echo "$commit"
break
fi
done | $HEAD -1)
if [ -z "$FILE_COMMIT" ]; then
echo " ✗ $file (no commit found where file exists)"
FAILED_COUNT=$((FAILED_COUNT + 1))
continue
fi
# Create directory if needed
$MKDIR -p "$($DIRNAME "$file")"
# Restore the file from its commit
if $GIT checkout "$FILE_COMMIT" -- "$file" 2>/dev/null; then
SHORT_COMMIT=$(echo "$FILE_COMMIT" | $CUT -c1-8)
echo " ✓ $file (from $SHORT_COMMIT)"
RESTORED_COUNT=$((RESTORED_COUNT + 1))
else
echo " ✗ $file (checkout failed from $FILE_COMMIT)"
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
done <<< "$MISSING_FILES"
echo
echo "Summary:"
echo " Restored: $RESTORED_COUNT"
[ "$EXISTING_COUNT" -gt 0 ] && echo " Skipped (already exist): $EXISTING_COUNT"
[ "$FAILED_COUNT" -gt 0 ] && echo " Failed: $FAILED_COUNT"
echo
echo "Run 'git status' to see the restored files"
echo "Run 'git add .' to stage them"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment