#!/usr/bin/env bash # # Check git repo's files .gitattributes and ensure all of them are mapped. # # Author: Ismo Vuorinen 2022 # License: MIT set -euo pipefail # Default configuration VERBOSE=0 CHECK_PATTERN="text: auto" EXIT_ON_MISSING=0 SUGGEST_RULES=1 # Suggestions enabled by default WRITE_RULES=0 # Writing to file is opt-in FORMAT_WIDTH=0 # Auto-width by default (0 means auto) MIN_FORMAT_WIDTH=20 # Minimum format width DEBUG="${DEBUG:-0}" if [ "$DEBUG" -eq 1 ]; then set -x fi # Output functions msg_err() { echo -e "\e[31m$@\e[0m" >&2 } msg_success() { echo -e "\e[32m$@\e[0m" } msg_warn() { echo -e "\e[33m$@\e[0m" >&2 } msg_info() { echo -e "\e[36m$@\e[0m" } msg_debug() { [[ $VERBOSE -eq 1 ]] && echo -e "\e[35m$@\e[0m" } show_help() { cat << EOF Usage: $(basename "$0") [OPTIONS] Check if all git-tracked files have corresponding rules in .gitattributes Options: -h, --help Display this help message -v, --verbose Enable verbose output -e, --exit Exit with error code if missing attributes found -p, --pattern Pattern to check (default: "text: auto") -n, --no-suggest Don't suggest .gitattributes rules (suggestions are on by default) -w, --write Write suggested rules to .gitattributes file -f, --format-width Specify width for formatting rule patterns (default: auto, min: $MIN_FORMAT_WIDTH) EOF } # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in -h | --help) show_help exit 0 ;; -v | --verbose) VERBOSE=1 shift ;; -e | --exit) EXIT_ON_MISSING=1 shift ;; -p | --pattern) CHECK_PATTERN="$2" shift 2 ;; -n | --no-suggest) SUGGEST_RULES=0 shift ;; -w | --write) WRITE_RULES=1 shift ;; -f | --format-width) if [[ $2 =~ ^[0-9]+$ ]]; then FORMAT_WIDTH=$2 shift 2 else msg_err "Error: --format-width requires a numeric argument" exit 1 fi ;; *) msg_err "Unknown option: $1" show_help exit 1 ;; esac done # Function to check if git is installed check_git_installed() { if ! command -v git &> /dev/null; then msg_err "git could not be found, please install it first" exit 1 fi } # Check if we're in a git repository check_git_repo() { if ! git rev-parse --is-inside-work-tree &> /dev/null; then msg_err "Not inside a git repository" exit 1 fi } # Check if we're in the git root directory check_git_root() { local git_root git_root=$(git rev-parse --show-toplevel) local current_dir current_dir=$(pwd) if [[ "$git_root" != "$current_dir" ]]; then msg_err "Not in git repository root directory" msg_warn "Please run this command from: $git_root" exit 1 fi } # Check if .gitattributes exists check_gitattributes_exists() { if [[ ! -f ".gitattributes" ]]; then msg_err ".gitattributes file not found in the repository root" msg_warn "Create a .gitattributes file before running this command" exit 1 fi } # Format rule with proper alignment format_rule() { local pattern="$1" local attributes="$2" local width="$3" # If pattern starts with "#", it's a comment, don't format if [[ "$pattern" == "#"* ]]; then echo "$pattern" return fi # If pattern is empty, return empty if [[ -z "$pattern" ]]; then echo "" return fi printf "%-${width}s %s\n" "$pattern" "$attributes" } # Get the file extension properly, handling special cases get_file_extension() { local file="$1" local basename=$(basename "$file") local extension="" # Check if file has no extension or is a dotfile if [[ "$basename" == .* && ! "$basename" =~ \..+$ ]]; then # It's a dotfile without extension (like .gitignore) extension="$basename" elif [[ "$basename" =~ \..+$ ]]; then # Normal file with extension extension="${basename##*.}" # Check for special cases like .d/ directories if [[ "$extension" == "d" ]]; then # This is likely a .d directory - use the full filename as pattern if [[ -f "$file" ]]; then # For files in .d directories, use the complete path as pattern extension=$(basename "$file") else # For .d directory itself, use *.d extension="d" fi fi else # No extension at all extension="$basename" fi echo "$extension" } # Suggest appropriate gitattributes rules based on file extension suggest_rule() { local file="$1" local extension="" local pattern="" local attributes="" msg_debug "Checking file: $file" # Skip directories if [[ -d "$file" ]]; then return fi # Get proper file extension extension=$(get_file_extension "$file") # If file path contains .d/ pattern, we need special handling if [[ "$file" =~ \.d/ ]]; then # Extract the pattern part that includes the .d/ directory local dir_part=$(dirname "$file") local base_name=$(basename "$file") # Check if it's a config directory pattern worth capturing if [[ "$dir_part" =~ /(\.d|[^/]+\.d)$ ]]; then pattern="$dir_part/*" msg_debug "Detected .d directory pattern: $pattern" else # Use standard extension pattern pattern="*.${extension}" fi else # Standard file with extension pattern="*.${extension}" fi # Common text files case "$extension" in # Shell scripts sh | bash | zsh | fish) attributes="text eol=lf diff=shell" ;; # Web development html | htm | xhtml | css | scss | sass | less) attributes="text eol=lf diff=html" ;; js | jsx | ts | tsx | json | json5) attributes="text eol=lf diff=javascript" ;; # Programming languages php) attributes="text eol=lf diff=php" ;; py) attributes="text eol=lf diff=python" ;; rb) attributes="text eol=lf diff=ruby" ;; go) attributes="text eol=lf diff=golang" ;; java | kt | scala) attributes="text eol=lf diff=java" ;; c | cpp | h | hpp) attributes="text eol=lf diff=cpp" ;; # Documentation md | markdown | txt) attributes="text eol=lf" ;; # Configuration files yml | yaml | toml | ini | cfg | conf) attributes="text eol=lf" ;; # Git config files and similar patterns git) attributes="text eol=lf" ;; gitignore | gitattributes) attributes="text eol=lf" ;; # Binary files png | jpg | jpeg | gif | ico | svg | webp | avif) attributes="binary" ;; pdf | doc | docx | xls | xlsx | ppt | pptx) attributes="binary" ;; zip | tar | gz | 7z | rar) attributes="binary" ;; mp3 | mp4 | avi | mov | wav | ogg) attributes="binary" ;; ttf | otf | woff | woff2 | eot) attributes="binary" ;; # Default for unknown extensions *) # Try to guess if it's text by checking if it contains null bytes if file "$file" | grep -q text; then attributes="text eol=lf" else attributes="binary" fi ;; esac msg_debug "...suggesting $pattern $attributes" echo "$pattern:$attributes" } # Function to check for missing .gitattributes check_gitattributes() { local missing_attributes msg_info "Checking for pattern: $CHECK_PATTERN" missing_attributes=$(git ls-files | git check-attr -a --stdin | grep "$CHECK_PATTERN" || true) if [[ -n "$missing_attributes" ]]; then msg_warn "Missing .gitattributes rules detected" if [[ $SUGGEST_RULES -eq 1 ]]; then # Generate suggestions local suggestions # Generate the suggestions suggestions=$(suggest_gitattributes "$missing_attributes") # Display the suggestions echo "" echo "$suggestions" echo "" if [[ $WRITE_RULES -eq 1 ]]; then msg_debug "...writing to .gitattributes" write_to_gitattributes "$suggestions" fi else msg_err ".gitattributes rule missing for the following files:" echo "$missing_attributes" fi if [[ $EXIT_ON_MISSING -eq 1 ]]; then return 1 fi else msg_success "All files have a corresponding rule in .gitattributes" fi return 0 } # Parse rule string and extract pattern and attributes parse_rule() { local rule="$1" if [[ "$rule" == "#"* ]]; then # This is a comment line echo "$rule::" return fi if [[ "$rule" =~ ^([^[:space:]]+)[[:space:]]+(.*)$ ]]; then echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}" else echo "$rule::" fi } # Check shell scripts by name regardless of extension detect_shell_scripts() { msg_debug "Detecting shell scripts by name regardless of extension..." local shell_scripts_rules="" local shell_scripts_found=0 local patterns=() local attributes=() # Get already defined rules in .gitattributes local existing_rules existing_rules=$(grep -v "^#" .gitattributes || true) # Get all shell scripts regardless of extension local shell_scripts shell_scripts=$(git ls-files | xargs file | grep "shell script" | cut -d: -f1) if [[ -n "$shell_scripts" ]]; then shell_scripts_found=$(echo "$shell_scripts" | wc -l | tr -d ' ') msg_debug "Found $shell_scripts_found potential shell scripts." # Track scripts that need rules declare -A scripts_by_dir=() local need_rule_count=0 # Process each script while IFS= read -r script; do local rel_path="${script#./}" # Skip if exact path already in .gitattributes if grep -q "^${rel_path} " <<< "$existing_rules"; then msg_debug "Script already in .gitattributes: $rel_path" continue fi # Skip if file extension already covered local extension=$(get_file_extension "$rel_path") if [[ "$extension" != "$rel_path" ]] && grep -q "^\*\.$extension " <<< "$existing_rules"; then msg_debug "Script covered by extension rule: $rel_path (*.$extension)" continue fi # Check if any parent directory is already covered local is_covered=0 local dir_path="$rel_path" while [[ "$dir_path" != "." && "$dir_path" != "/" ]]; do dir_path=$(dirname "$dir_path") if [[ "$dir_path" == "." ]]; then break fi # Check if directory or any of its contents are covered if grep -q "^${dir_path}/\?" <<< "$existing_rules" || grep -q "^${dir_path}/\*" <<< "$existing_rules"; then msg_debug "Script covered by directory rule: $rel_path (${dir_path})" is_covered=1 break fi done if [[ $is_covered -eq 1 ]]; then continue fi # Group by directory local dir=$(dirname "$rel_path") if [[ "$dir" == "." ]]; then dir="root" fi # Add to appropriate group if [[ -z "${scripts_by_dir[$dir]:-}" ]]; then scripts_by_dir[$dir]="$rel_path" else scripts_by_dir[$dir]="${scripts_by_dir[$dir]}\n$rel_path" fi ((need_rule_count++)) done <<< "$shell_scripts" # Output grouped results if [[ $need_rule_count -gt 0 ]]; then patterns+=("# Shell scripts detected by content") attributes+=("") # Check if we can use directory-based rules instead of individual files for dir in "${!scripts_by_dir[@]}"; do local files_in_dir=$(echo -e "${scripts_by_dir[$dir]}" | wc -l) local dir_path="$dir" if [[ "$dir" == "root" ]]; then # For root directory files, list each individually while IFS= read -r file; do patterns+=("$file") attributes+=("text eol=lf diff=shell") done <<< "$(echo -e "${scripts_by_dir[$dir]}")" elif [[ $files_in_dir -gt 2 ]]; then # If directory has multiple scripts, suggest a directory pattern patterns+=("# Found $files_in_dir scripts in $dir_path") attributes+=("") # Special handling for .d directories if [[ "$dir_path" =~ \.d$ || "$dir_path" =~ \.d/ ]]; then patterns+=("$dir_path/*") else patterns+=("$dir_path/*") fi attributes+=("text eol=lf diff=shell") # List the files as comments for reference while IFS= read -r file; do patterns+=("# - ${file}") attributes+=("") done <<< "$(echo -e "${scripts_by_dir[$dir]}" | sort)" else # For directories with few scripts, list them individually while IFS= read -r file; do patterns+=("$file") attributes+=("text eol=lf diff=shell") done <<< "$(echo -e "${scripts_by_dir[$dir]}")" fi done msg_debug "Adding $need_rule_count shell scripts to suggestions (grouped by directory)." else msg_debug "All detected shell scripts already have rules." fi else msg_debug "No shell scripts detected." fi # Return the formatted arrays local rules_count=${#patterns[@]} for ((i = 0; i < rules_count; i++)); do echo "${patterns[$i]}:${attributes[$i]}" done } # Function to suggest gitattributes rules suggest_gitattributes() { local missing_attributes="$1" local files local extension_suggestions=() local formatted_suggestions="" local all_patterns=() local all_attributes=() local max_pattern_length=0 # Add header to suggestions all_patterns+=("# Auto-generated rules - $(date +"%Y-%m-%d %H:%M:%S")") all_attributes+=("") msg_info "Suggested .gitattributes rules:" # First, detect shell scripts and add them to suggestions msg_info "Detecting shell scripts by content..." local shell_scripts_rules shell_scripts_rules=$(detect_shell_scripts) # Add shell script rules to patterns and attributes arrays if [[ -n "$shell_scripts_rules" ]]; then while IFS=':' read -r pattern attributes; do if [[ -n "$pattern" ]]; then all_patterns+=("$pattern") all_attributes+=("$attributes") # Update max pattern length (skip comments) if [[ "$pattern" != "#"* ]] && [[ ${#pattern} -gt $max_pattern_length ]]; then max_pattern_length=${#pattern} fi fi done <<< "$shell_scripts_rules" fi # Extract filenames from git check-attr output files=$(echo "$missing_attributes" | awk -F': ' '{print $1}') # Get suggestions for each file declare -A seen_patterns=() while IFS= read -r file; do local suggestion=$(suggest_rule "$file") if [[ -n "$suggestion" ]]; then IFS=':' read -r pattern attributes <<< "$suggestion" # Only add each pattern once if [[ -z "${seen_patterns[$pattern]:-}" ]]; then extension_suggestions+=("$suggestion") seen_patterns[$pattern]=1 fi fi done <<< "$files" # Remove duplicates and sort local unique_extensions=() mapfile -t unique_extensions < <(printf '%s\n' "${extension_suggestions[@]}" | sort -u) # Add extension-based suggestions header if we have any if [[ ${#unique_extensions[@]} -gt 0 ]]; then all_patterns+=("") all_attributes+=("") all_patterns+=("# File extension-based rules") all_attributes+=("") # Add extension rules to patterns and attributes arrays for suggestion in "${unique_extensions[@]}"; do IFS=':' read -r pattern attributes <<< "$suggestion" all_patterns+=("$pattern") all_attributes+=("$attributes") # Update max pattern length if [[ ${#pattern} -gt $max_pattern_length ]]; then max_pattern_length=${#pattern} fi done fi # Use user-specified format width if provided, otherwise use max_pattern_length # But ensure it's at least MIN_FORMAT_WIDTH local format_width=$max_pattern_length if [[ $FORMAT_WIDTH -gt 0 ]]; then format_width=$FORMAT_WIDTH fi # Ensure minimum width if [[ $format_width -lt $MIN_FORMAT_WIDTH ]]; then format_width=$MIN_FORMAT_WIDTH fi msg_debug "Using format width: $format_width" # Format and output all suggestions with proper alignment local rule_count=${#all_patterns[@]} for ((i = 0; i < rule_count; i++)); do local pattern="${all_patterns[$i]}" local attributes="${all_attributes[$i]}" # Handle comments separately if [[ "$pattern" == "#"* ]] || [[ -z "$attributes" ]]; then formatted_suggestions+="$pattern\n" echo "$pattern" else local formatted_rule=$(printf "%-${format_width}s %s\n" "$pattern" "$attributes") formatted_suggestions+="$formatted_rule\n" echo "$formatted_rule" fi done # Add final message echo "" msg_info "Add these rules to your .gitattributes file to resolve missing attributes." # Return the full suggestion text so it can be both displayed and written to file echo -e "$formatted_suggestions" } # Write suggestions to .gitattributes file write_to_gitattributes() { local suggestions="$1" local gitattributes=".gitattributes" # Check if file exists and is writable if [[ ! -w "$gitattributes" ]]; then msg_err "Cannot write to $gitattributes. Check permissions." return 1 fi # Add a newline at the end of the file if it doesn't have one if [[ -s "$gitattributes" ]] && [[ $(tail -c 1 "$gitattributes" | wc -l) -eq 0 ]]; then echo "" >> "$gitattributes" fi # Append suggestions to the file echo -e "$suggestions" >> "$gitattributes" msg_success "Added suggested rules to $gitattributes" # Remind to check the file msg_warn "Please review the changes to ensure they're appropriate for your project." return 0 } # Main function main() { check_git_installed check_git_repo check_git_root check_gitattributes_exists check_gitattributes } main "$@"