#!/usr/bin/env bash # Version: 0.0.2 # Copyright (c) 2023: # Darren 'Tadgy' Austin # Licensed under the terms of the GNU General Public License version 3. # Defaults. DB_FILE=".gitattributsdb" # Database file, relative to the repository root. DB_EXTRA=".gitattributsdb-extra" # List of base64 encoded filenames (one per line) to also store/restore attributes for. # To add entries to this file, use: printf "%s" "" | base64 -w 0 >>.gitattributsdb-extra # Where '' is relative to the repository root. # Variables. declare -A DB_ACLS DB_ATIMES DB_MODES DB_MTIMES DB_OWNERSHIPS # shellcheck disable=SC2155 declare PLATFORM="$(uname -s)" # Function to output a log/info message. log() { printf "%s: %s\\n" "${0##*/}" "$*" } # Function to output a warning message to stderr. warn() { log "Warning:" "$*" >&2 } # Function to output an error message to stderr and die. error() { log "Error:" "$*" >&2 exit 1 } # Function to display help. show_help() { local SCRIPT="${0##*/}" #........1.........2.........3.........4.........5.........6.........7.........8 cat <<-EOF Usage: $SCRIPT [options] Store and restore file attributes for files within the git repository from a database stored within the repository itself. This program is intended to be called from git hooks, rather than directly. # FIXME: Write some info about using as a submodule and as a hook in .githooks/. EOF } # Function to read the database into an array. read_db() { local ACL ATIME FILENAME MODE MTIME OWNERSHIP # Do nothing if the DB file doesn't exist. [[ ! -e "$DB_FILE" ]] && return 0 # Read the file. while read -r FILENAME MTIME ATIME OWNERSHIP MODE ACL; do # Store the attributes in arrays. DB_MTIMES[$FILENAME]="$MTIME" DB_ATIMES[$FILENAME]="$ATIME" DB_OWNERSHIPS[$FILENAME]="$OWNERSHIP" DB_MODES[$FILENAME]="$MODE" DB_ACLS[$FILENAME]="$ACL" done < <(grep -Ev '^(#|$)' "$DB_FILE") return 0 } # Function to store file attributes into the database. store_attributes() { local COUNT=0 DB_TMP FILE X # Informational message. log "Storing file attributes into database" # Use a temporary file for the new database. DB_TMP="$(mktemp "$DB_FILE.XXXXXX" 2>/dev/null)" || error "Failed to create temporary database file" # While Darwin supports ACLs, there is no standard output and input format for them - don't even try. [[ "$PLATFORM" == "Darwin" ]] && warn "Not storing ACLs on Darwin" # File header. printf "# %s\\n" "This is the gitattributsdb database file." >"$DB_TMP" printf "# %s\\n\\n" "Do not manually edit this file - any changes will be overwritten." >>"$DB_TMP" # Create the database. while read -r -d $'\0' FILE; do # No need to process the database files themselves. [[ "$FILE" == "$DB_FILE" ]] || [[ "$FILE" == "$DB_EXTRA" ]] && continue if [[ "$PLATFORM" == "Linux" ]]; then # On Linux, we can handle ACLs too. printf "%s %s %s\\n" "$(printf "%s" "$FILE" | base64 -w 0 2>/dev/null)" "$(stat --printf '%.9Y %.9X %U:%G %.4a' -- "$FILE" 2>/dev/null)" \ "$(getfacl -cEsp -- "$FILE" 2>/dev/null | base64 -w 0 2>/dev/null)" >>"$DB_TMP" elif [[ "$PLATFORM" == "Darwin" ]]; then # Darwin just has to be different. # Use the full path to Darwin's stat, in case there's a macports/brew/etc version installed. printf "%s %s\\n" "$(printf "%s" "$FILE" | base64 -b 0 2>/dev/null)" "$(/usr/bin/stat -f '%Fm %Fa %Su:%Sg %Mp%Lp' -- "$FILE" 2>/dev/null)" >>"$DB_TMP" fi (( COUNT++ )) done < <(git ls-files -z --full-name -- . 2>/dev/null; while read -r X; do printf "%s\\0" "$(printf "%s" "$X" | base64 -d 2>/dev/null)"; done < \ <(grep -Ev '^(#|$)' "$DB_EXTRA" 2>/dev/null)) # Move the temporary file into place. mv -- "$DB_TMP" "$DB_FILE" 2>/dev/null || { rm -f -- "$DB_TMP"; error "Failed to move database temporary file into place"; } log "$COUNT entries stored" # Add the databases themselves to the commit. git add --all -f -- "$DB_EXTRA" 2>/dev/null # OK to fail silently. git add --all -f -- "$DB_FILE" 2>/dev/null || error "Failed to add database files to commit" return 0 } # Function to restore file attributes from the database. restore_attributes() { local COUNT=0 FILE ID # Informational message. log "Restoring file attributes from database" # Read the database. read_db # While Darwin supports ACLs, there is no standard output and input format for them - don't even try. [[ "$PLATFORM" == "Darwin" ]] && warn "Not restoring ACLs on Darwin" # Restore from the read database. while read -r ID; do # Decode the filename from the array ID. FILE="$(printf "%s" "$ID" | base64 -d 2>/dev/null)" || { warn "Failed to decode filename: $ID"; continue; } # Ignore empty filenames, or non-existant files. [[ -z "$FILE" ]] || [[ ! -e "$FILE" ]] && continue # Don't restore attributes for symlinks. [[ -L "$FILE" ]] && warn "Not restoring attributes for symlink: $FILE" && continue # Restore ownerships. chown -- "${DB_OWNERSHIPS[$ID]}" "$FILE" 2>/dev/null || warn "Failed to restore ownership: $FILE" # Store mode. chmod -- "${DB_MODES[$ID]}" "$FILE" 2>/dev/null || warn "Failed to restore permissions: $FILE" # Restore ACLs on Linux. if [[ "$PLATFORM" == "Linux" ]]; then touch -m --date="$(date --date="19700101 00:00:00 + ${DB_MTIMES[$ID]} seconds" +'%Y/%m/%d %H:%M:%S.%N' 2>/dev/null)" -- "$FILE" 2>/dev/null || \ warn "Failed to restore mtime: $FILE" touch -a --date="$(date --date="19700101 00:00:00 + ${DB_ATIMES[$ID]} seconds" +'%Y/%m/%d %H:%M:%S.%N' 2>/dev/null)" -- "$FILE" 2>/dev/null || \ warn "Failed to restore atime: $FILE" printf "%s" "${DB_ACLS[$ID]}" | base64 -d 2>/dev/null | setfacl -M - -- "$FILE" 2>/dev/null || warn "Failed to restore ACLs: $FILE" elif [[ "$PLATFORM" == "Darwin" ]]; then touch -m -d "$(date -j -r "${DB_MTIMES[$ID]%.*}" +"%Y-%m-%dT%H:%M:%S.${DB_MTIMES[$ID]#*.}")" -- "$FILE" 2>/dev/null || \ warn "Failed to restore mtime: $FILE" touch -a -d "$(date -j -r "${DB_MTIMES[$ID]%.*}" +"%Y-%m-%dT%H:%M:%S.${DB_MTIMES[$ID]#*.}")" -- "$FILE" 2>/dev/null || \ warn "Failed to restore atime: $FILE" fi (( COUNT++ )) done < <(printf "%s\\n" "${!DB_OWNERSHIPS[@]}") log "$COUNT entries restored" return 0 } # Exit if bash isn't v4+. (( BASH_VERSINFO[0] < 4 )) && error "Bash v4 or later is required" # Change to the root directory of the repository. REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" [[ -z "$REPO_ROOT" ]] && error "Could not determine git repository root" pushd -- "$REPO_ROOT" >/dev/null 2>&1 || error "Failed to switch to git repository root" # Parse command line. (( $# == 0 )) && { printf "%s: %s\\n" "${0##*/}" "missing argument" >&2 printf "%s: %s %s\\n" "Try" "${0##*/}" "--help" >&2 exit 1 } case "$1" in '-h'|'--h'|'--help') show_help ;; 'post-checkout'|'post-merge') # Restore the file attributes from the database. restore_attributes ;; 'pre-commit') # Store the file attributes into the database. store_attributes ;; *) printf "%s: %s: %s\\n" "${0##*/}" "invalid option" "$1" >&2 printf "%s: %s %s\\n" "Try" "${0##*/}" "--help" >&2 exit 1 ;; esac exit 0