Skip to content

Instantly share code, notes, and snippets.

@brandonzylstra
Created June 11, 2025 22:39
Show Gist options
  • Save brandonzylstra/e37172ea0b78f84f2356bc42ae1f88e6 to your computer and use it in GitHub Desktop.
Save brandonzylstra/e37172ea0b78f84f2356bc42ae1f88e6 to your computer and use it in GitHub Desktop.
Synchronize .gitignore patterns with tracked files
#!/usr/bin/env zsh
#
# git-clean-ignore: Synchronize .gitignore patterns with tracked files
#
# This script scans a Git repository for files that match patterns in .gitignore
# but are still being tracked. It prompts the user to either remove these files
# from tracking or remove the corresponding patterns from .gitignore.
#
# Usage:
# git-clean-ignore [options]
#
# Options:
# --verbose Show detailed information about all patterns
# --clean repo Automatically remove ignored files from tracking
# --clean gitignore Remove patterns from .gitignore that match tracked files
# --help Show this help message
# Text formatting
bold=$(tput bold)
normal=$(tput sgr0)
red=$(tput setaf 1)
green=$(tput setaf 2)
yellow=$(tput setaf 3)
blue=$(tput setaf 4)
# Default options
VERBOSE=false
AUTO_CLEAN_REPO=false
AUTO_CLEAN_GITIGNORE=false
# Helper functions
function print_help() {
echo "${bold}Git Ignore Cleanup Utility${normal}"
echo ""
echo "Usage:"
echo " $(basename $0) [options]"
echo ""
echo "Options:"
echo " --verbose Show detailed information about all patterns"
echo " --clean repo Automatically remove ignored files from tracking"
echo " --clean gitignore Remove patterns from .gitignore that match tracked files"
echo " --help Show this help message"
exit 0
}
function print_header() {
echo "\n${bold}${blue}$1${normal}\n"
}
function print_success() {
echo "${green}✓${normal} $1"
}
function print_warning() {
echo "${yellow}!${normal} $1"
}
function print_error() {
echo "${red}✗${normal} $1"
}
function ask_yes_no() {
local prompt="$1"
local default="$2"
local yn
if [[ "$default" == "y" ]]; then
prompt="$prompt [Y/n] "
else
prompt="$prompt [y/N] "
fi
read -r "yn?$prompt"
case "$yn" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
*)
if [[ "$default" == "y" ]]; then
return 0
else
return 1
fi
;;
esac
}
function is_git_repo() {
git rev-parse --is-inside-work-tree &>/dev/null
}
function get_repo_root() {
git rev-parse --show-toplevel
}
# Parse command line arguments
for arg in "$@"; do
case "$arg" in
--verbose)
VERBOSE=true
;;
--clean)
echo "Error: The --clean option requires an argument (repo or gitignore)"
exit 1
;;
--clean=*)
CLEAN_TARGET=${arg#*=}
if [[ "$CLEAN_TARGET" == "repo" ]]; then
AUTO_CLEAN_REPO=true
elif [[ "$CLEAN_TARGET" == "gitignore" ]]; then
AUTO_CLEAN_GITIGNORE=true
else
echo "Error: Invalid --clean target: $CLEAN_TARGET"
echo "Valid targets are 'repo' or 'gitignore'"
exit 1
fi
;;
--clean\ repo)
AUTO_CLEAN_REPO=true
;;
--clean\ gitignore)
AUTO_CLEAN_GITIGNORE=true
;;
-h|--help)
print_help
;;
*)
echo "Unknown option: $arg"
print_help
;;
esac
shift
done
# Validate requirements
if ! is_git_repo; then
print_error "Not a Git repository. Please run this script from within a Git repository."
exit 1
fi
REPO_ROOT=$(get_repo_root)
GITIGNORE_FILE="${REPO_ROOT}/.gitignore"
if [[ ! -f "$GITIGNORE_FILE" ]]; then
print_error "No .gitignore file found at $GITIGNORE_FILE"
exit 1
fi
# Initial message
if $VERBOSE; then
print_header "Git Ignore Synchronization Tool"
echo "This tool will help you synchronize your .gitignore patterns with tracked files."
echo "It will find files that are currently tracked but match ignore patterns."
fi
# Get all ignored but tracked files in one go first
if $VERBOSE; then
print_header "Finding tracked files that should be ignored..."
fi
TOTAL_MATCHED_FILES=0
PATTERNS_TO_REMOVE=()
MATCHED_PATTERNS=()
MATCHED_FILES_MAP=()
# Collect all ignored files that are still tracked
all_tracked_ignored_files=$(git ls-files --cached -i --exclude-from="$GITIGNORE_FILE" 2>/dev/null)
if [[ -z "$all_tracked_ignored_files" ]]; then
if $VERBOSE; then
print_success "No tracked files matching ignore patterns were found."
echo "Your repository is in sync with your .gitignore file!"
else
print_success "No tracked files matching ignore patterns."
fi
exit 0
fi
# Count matched files
matched_files_count=$(echo "$all_tracked_ignored_files" | wc -l | tr -d ' ')
print_warning "Found ${bold}$matched_files_count${normal} tracked files that match patterns in .gitignore:"
echo "$all_tracked_ignored_files" | sed 's/^/ - /'
# Process each file to map it to the pattern that's ignoring it
while read -r file; do
matched_pattern=""
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then
continue
fi
# Extract the pattern (ignore inline comments)
pattern=$(echo "$line" | sed 's/\s*#.*$//')
pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ -z "$pattern" ]]; then
continue
fi
# Check if this pattern matches the file
if git check-ignore -q --no-index --exclude="$pattern" "$file" 2>/dev/null; then
matched_pattern="$pattern"
if ! [[ " ${MATCHED_PATTERNS[@]} " =~ " ${pattern} " ]]; then
MATCHED_PATTERNS+=("$pattern")
MATCHED_FILES_MAP+=("$pattern:$file")
else
MATCHED_FILES_MAP+=("$pattern:$file")
fi
break
fi
done < "$GITIGNORE_FILE"
done <<< "$all_tracked_ignored_files"
# Now handle each pattern with its matched files
for pattern in "${MATCHED_PATTERNS[@]}"; do
# Get all files matching this pattern
matched_files=""
for entry in "${MATCHED_FILES_MAP[@]}"; do
IFS=':' read -r p f <<< "$entry"
if [[ "$p" == "$pattern" ]]; then
matched_files="$matched_files$f\n"
fi
done
matched_files=$(echo "$matched_files" | sed '/^$/d')
# Count matched files for this pattern
matched_count=$(echo "$matched_files" | wc -l | tr -d ' ')
if $VERBOSE; then
echo "Pattern: ${bold}$pattern${normal} matches ${bold}$matched_count${normal} files:"
echo "$matched_files" | sed 's/^/ - /'
echo
else
echo "Pattern: ${bold}$pattern${normal} matches ${bold}$matched_count${normal} files."
fi
# Handle auto-clean modes or prompt user
if $AUTO_CLEAN_REPO; then
echo "Removing matched files from tracking (--clean repo specified)..."
echo "$matched_files" | xargs git rm --cached -q
print_success "Files removed from tracking but kept on disk."
elif $AUTO_CLEAN_GITIGNORE; then
PATTERNS_TO_REMOVE+=("$pattern")
print_warning "Pattern '$pattern' marked for removal from .gitignore (--clean gitignore specified)."
else
# Interactive mode - prompt the user
echo "Found ${bold}$matched_count${normal} tracked files matching pattern: ${bold}$pattern${normal}"
if $VERBOSE; then
echo "$matched_files" | sed 's/^/ - /'
else
echo "Use --verbose to see the list of files"
fi
echo "Choose an action:"
echo " 1) Remove these files from Git tracking (keep on disk)"
echo " 2) Keep tracking these files (remove pattern from .gitignore)"
echo " 3) Skip this pattern (no action)"
read -r "choice?Your choice [1-3]: "
case "$choice" in
1)
echo "Removing files from tracking..."
echo "$matched_files" | xargs git rm --cached -q
print_success "Files removed from tracking but kept on disk."
;;
2)
PATTERNS_TO_REMOVE+=("$pattern")
print_warning "Pattern '$pattern' marked for removal from .gitignore."
;;
3)
print_warning "No action taken for pattern '$pattern'."
;;
*)
print_error "Invalid choice. Skipping this pattern."
;;
esac
fi
echo ""
done
# Remove patterns from .gitignore if needed
if [[ ${#PATTERNS_TO_REMOVE[@]} -gt 0 ]]; then
print_header "Updating .gitignore file..."
if ! $AUTO_CLEAN_GITIGNORE; then
echo "The following patterns will be removed from .gitignore:"
for pattern in "${PATTERNS_TO_REMOVE[@]}"; do
echo " - $pattern"
done
if ! ask_yes_no "Proceed with removing these patterns?" "y"; then
print_warning "No changes made to .gitignore file."
PATTERNS_TO_REMOVE=()
fi
fi
if [[ ${#PATTERNS_TO_REMOVE[@]} -gt 0 ]]; then
temp_file=$(mktemp)
while IFS= read -r line || [[ -n "$line" ]]; do
pattern_match=0
# Keep comments and empty lines
if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then
echo "$line" >> "$temp_file"
continue
fi
# Extract the pattern (ignore inline comments)
pattern=$(echo "$line" | sed 's/\s*#.*$//')
pattern=$(echo "$pattern" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
for remove_pattern in "${PATTERNS_TO_REMOVE[@]}"; do
if [[ "$pattern" == "$remove_pattern" ]]; then
pattern_match=1
break
fi
done
# Keep the line if pattern doesn't match any to remove
if [[ $pattern_match -eq 0 ]]; then
echo "$line" >> "$temp_file"
else
print_warning "Removed pattern: $pattern"
fi
done < "$GITIGNORE_FILE"
cp "$temp_file" "$GITIGNORE_FILE"
rm "$temp_file"
print_success "Updated .gitignore file."
fi
fi
# Summary - only show in verbose mode
if $VERBOSE; then
print_header "Summary"
# Check if we originally found tracked ignored files (rely on the original variable)
if [[ -z "$all_tracked_ignored_files" ]]; then
print_success "No tracked files matching ignore patterns were found."
echo "Your repository is in sync with your .gitignore file!"
else
if [[ ${#PATTERNS_TO_REMOVE[@]} -gt 0 ]]; then
print_success "Removed ${#PATTERNS_TO_REMOVE[@]} patterns from .gitignore."
fi
echo "\nNext steps:"
echo " 1. Review any modified files with: ${bold}git status${normal}"
echo " 2. Commit your changes with: ${bold}git commit -m \"Update ignored files and patterns\"${normal}"
fi
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment