From 3d9e0477b093fb40d6b96f560a19b197fdd2f9c7 Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Tue, 22 Apr 2025 10:11:32 +0300 Subject: [PATCH] feat(bin): git-attributes rewrite --- .gitattributes | 45 ++- local/bin/git-attributes | 675 +++++++++++++++++++++++++++++++ local/bin/x-check-git-attributes | 41 -- 3 files changed, 713 insertions(+), 48 deletions(-) create mode 100755 local/bin/git-attributes delete mode 100755 local/bin/x-check-git-attributes diff --git a/.gitattributes b/.gitattributes index f3e1f99..9ea3b43 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -## GITATTRIBUTES FOR WEB PROJECTS +## GITATTRIBUTES # # These settings are for any web project. # @@ -20,20 +20,23 @@ *.bat text eol=crlf *.cmd text eol=crlf *.coffee text -*.css text diff=css -*.htm text diff=html -*.html text diff=html +*.css text diff=css eol=lf +*.fish text diff=shell eol=lf +*.htm text diff=html eol=lf +*.html text diff=html eol=lf *.inc text *.ini text *.js text *.json text *.jsx text *.less text +*.lua text diff=lua eol=lf *.ls text *.map text -diff *.od text *.onlydata text *.php text diff=php +*.plist text eol=lf *.pl text *.ps1 text eol=crlf *.py text diff=python @@ -41,15 +44,18 @@ *.sass text *.scm text *.scss text diff=css -*.sh text eol=lf +*.sh text eol=lf diff=shell .husky/* text eol=lf *.sql text *.styl text *.tag text +*.tmux text eol=lf diff=tmux *.ts text *.tsx text +*.vim text eol=lf *.xml text *.xhtml text diff=html +*.zsh text diff=zsh eol=lf # Docker Dockerfile text @@ -68,6 +74,7 @@ Dockerfile text AUTHORS text CHANGELOG text CHANGES text +CODEOWNERS text CONTRIBUTING text COPYING text copyright text @@ -105,6 +112,8 @@ TODO text *.config text .editorconfig text .env text +*.env text +*.env.* text .gitattributes text .gitconfig text .htaccess text @@ -208,15 +217,37 @@ Procfile text *.gitignore text *.gitkeep text -.gitattributes export-ignore +.gitattributes text export-ignore +*.gitattributes text export-ignore +.gitmodules text export-ignore +*.gitmodules text export-ignore **/.gitignore export-ignore **/.gitkeep export-ignore # Repo specials -local/bin/* text eol=lf +local/bin/* text eol=lf diff=shell +local/bin/*.md text eol=lf diff=markdown config/antigen.zsh text git/* text **/git/* text **/alias text ssh/* text +ssh/shared.d/* text +ssh/local.d/* text +# Auto-generated rules - 2025-04-16 10:28:04 +# Shell scripts detected by content +install text eol=lf diff=shell + +# File extension-based rules +*.1 text eol=lf +*.applescript text eol=lf +*.d/work-git text eol=lf +*.dirs text eol=lf +*.example text eol=lf +*.itermcolors text eol=lf +*.locale text eol=lf +*.python-version text eol=lf +*.snippets text eol=lf +*.theme text eol=lf +*.yamlfmt text eol=lf diff --git a/local/bin/git-attributes b/local/bin/git-attributes new file mode 100755 index 0000000..bcd16bf --- /dev/null +++ b/local/bin/git-attributes @@ -0,0 +1,675 @@ +#!/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> "$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 "$@" diff --git a/local/bin/x-check-git-attributes b/local/bin/x-check-git-attributes deleted file mode 100755 index 949f2a4..0000000 --- a/local/bin/x-check-git-attributes +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# -# Check git repo's files .gitattributes and ensure all of them are mapped. -# Ismo Vuorinen 2022 -source "${DOTFILES}/config/shared.sh" - -set -euo pipefail - -# Enable verbosity with VERBOSE=1 -VERBOSE="${VERBOSE:-0}" - -# 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" - fi -} - -# Function to check for missing .gitattributes -check_gitattributes() -{ - local missing_attributes - missing_attributes=$(git ls-files | git check-attr -a --stdin | grep "text: auto" || true) - - if [[ -n "$missing_attributes" ]]; then - echo ".gitattributes rule missing for the following files:" - echo "$missing_attributes" - else - echo "All files have a corresponding rule in .gitattributes" - fi -} - -# Main function -main() -{ - check_git_installed - check_gitattributes -} - -main "$@"