Created
February 4, 2026 10:51
-
-
Save thoroc/725fb24537dde3b6927a797d6be04d45 to your computer and use it in GitHub Desktop.
restore file from history
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 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