Files
dotfiles/local/bin/git-attributes

676 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Check git repo's files .gitattributes and ensure all of them are mapped.
#
# Author: Ismo Vuorinen <https://github.com/ivuorinen> 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 "$@"