gitattributesdb/gitattributesdb

311 lines
10 KiB
Bash
Executable file

#!/usr/bin/env bash
# Version: 0.1.1
# Copyright (c) 2023-2024:
# Darren 'Tadgy' Austin <darren (at) afterdark.org.uk>
# Licensed under the terms of the GNU General Public License version 3.
# shellcheck disable=SC2317
# Defaults.
DB_FILE=".gitattributesdb" # Database file, relative to the repository root.
DB_EXTRA=".gitattributesdb-extra" # List of base64 encoded paths (one per line) to also store/restore attributes for.
# To add entries to this file, use: { printf "%s" <path> | base64 -w 0; printf "\\n"; } >>.gitattributesdb-extra
# Where '<path>' is relative to the repository root.
# Variables.
declare -A DB_ACLS DB_ATIMES DB_MODES DB_MTIMES DB_OWNERSHIPS DB_XATTRS
# shellcheck disable=SC2155
declare FUNC="" PLATFORM="$(uname -s)" VERBOSE=0
# 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] <hook name>
Store and restore attributes for paths within the git repository from a database
stored within the repository itself.
Options:
-h|--help Display this help page.
-v|--verbose Output path names as they are processed.
<hook name> must be one of: 'post-checkout', 'post-merge' or 'pre-commit', which
are the git hook names that call this hook script. See the README.md file for
full installation/usage instructions.
This program is intended to be called from git hooks, rather than directly.
Copyright (c) 2023-2024:
Darren 'Tadgy' Austin <darren (at) afterdark.org.uk>
Licensed under the terms of the GNU General Public License version 3.
EOF
}
# Function to read the database into an array.
read_db_entries() {
local ACL ATIME MODE MTIME OWNERSHIP PATHNAME XATTR
# Do nothing if the DB file doesn't exist, isn't a regular file or is empty.
[[ ! -f "$DB_FILE" ]] || [[ ! -s "$DB_FILE" ]] && return 0
# Read the file.
while read -r PATHNAME MTIME ATIME OWNERSHIP MODE ACL XATTR; do
# Store the attributes in arrays.
DB_MTIMES[$PATHNAME]="$MTIME"
DB_ATIMES[$PATHNAME]="$ATIME"
DB_OWNERSHIPS[$PATHNAME]="$OWNERSHIP"
DB_MODES[$PATHNAME]="$MODE"
DB_ACLS[$PATHNAME]="$ACL"
DB_XATTRS[$PATHNAME]="$XATTR"
done < <(grep -Ev '^(#|$)' "$DB_FILE")
return 0
}
# Function to store path attributes into the database.
add_db_entry() {
local ERR=0
[[ -z "$1" ]] && return 1
if [[ "$PLATFORM" == "Linux" ]]; then
# On Linux, we can handle ACLs and xattrs too.
ACL="$(getfacl -cEsp -- "$1" 2>/dev/null | base64 -w 0 2>/dev/null)"
XATTR="$(getfattr -dh -- "$1" 2>/dev/null | base64 -w 0 2>/dev/null)"
if printf "%s %s %s %s\\n" "$(printf "%s" "$1" | base64 -w 0 2>/dev/null)" "$(TZ=UTC stat -c '%.9Y %.9X %U:%G %.4a' -- "$1" 2>/dev/null)" "${ACL:--}" \
"${XATTR:--}" >>"$DB_TMP"; then
(( VERBOSE == 1 )) && log "$1"
else
warn "Failed to add database entry: $1"
ERR=1
fi
elif [[ "$PLATFORM" == "Darwin" ]]; then
# Darwin just has to be different, so no ACLs or xattrs.
# Use the full path to Darwin's stat, in case there's a macports/brew/etc version installed.
if printf "%s %s\\n" "$(printf "%s" "$1" | base64 -b 0 2>/dev/null)" "$(TZ=UTC /usr/bin/stat -f '%Fm %Fa %Su:%Sg %Mp%Lp' -- "$1" 2>/dev/null)" >>"$DB_TMP"; then
(( VERBOSE == 1 )) && log "$1"
else
warn "Failed to add database entry: $1"
ERR=1
fi
else
error "Unsupported platform: $PLATFORM"
fi
return "$ERR"
}
# Process the paths to add to the database.
store_attributes() {
local ACL ADD_COUNT=0 DB_TMP ERR_COUNT=0 EXTRA NAME PATHNAME XATTR
# Informational message.
log "Storing path 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 or xattrs on Darwin"
# File header.
printf "# %s\\n" "This is the gitattributesdb 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' PATHNAME; do
# No need to process the database files themselves.
[[ "$PATHNAME" == "$DB_FILE" ]] || [[ "$PATHNAME" == "$DB_EXTRA" ]] && continue
# Add the path's attributes to the database.
if add_db_entry "$PATHNAME"; then
(( ADD_COUNT++ ))
else
(( ERR_COUNT++ ))
fi
done < <(git ls-files -z --full-name -- . 2>/dev/null)
while read -r -d $'\0' PATHNAME; do
# PATHNAME should not be quoted - it needs to be expanded.
for NAME in $PATHNAME; do
# If the path doesn't exist, ignore it.
[[ ! -e "$NAME" ]] && continue
# No need to process the database files themselves.
[[ "$NAME" == "$DB_FILE" ]] || [[ "$NAME" == "$DB_EXTRA" ]] && continue
# Add the path's attributes to the database.
if add_db_entry "$NAME"; then
(( ADD_COUNT++ ))
else
(( ERR_COUNT++ ))
fi
done
done < <(while read -r EXTRA; do printf "%s%b" "$(printf "%s" "$EXTRA" | base64 -d 2>/dev/null)" "\\0"; 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"; }
(( ADD_COUNT > 0 )) && log "$ADD_COUNT entries stored"
(( ERR_COUNT > 0 )) && warn "$ERR_COUNT failied entries"
# 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 path attributes from the database.
restore_attributes() {
local COUNT=0 ID FMT PATHNAME PREV_WARN=0 WARN=0
# Informational message.
log "Restoring path attributes from database"
# Read the database.
read_db_entries
# While Darwin supports ACLs, there is no standard output and input format for them - don't even try.
[[ "$PLATFORM" == "Darwin" ]] && warn "Not restoring ACLs or xattrs on Darwin"
# Restore from the read database.
while read -r ID; do
# Decode the path name from the array ID.
PATHNAME="$(printf "%s" "$ID" | base64 -d 2>/dev/null)" || { warn "Failed to decode path: $ID"; continue; }
# Ignore empty path names, or non-existant paths.
[[ -z "$PATHNAME" ]] || [[ ! -e "$PATHNAME" ]] && continue
# Don't restore attributes for symlinks.
[[ -L "$PATHNAME" ]] && warn "Not restoring attributes for symlink: $PATHNAME" && continue
# Restore ownerships.
chown -- "${DB_OWNERSHIPS[$ID]}" "$PATHNAME" 2>/dev/null || {
warn "Failed to restore ownership: $PATHNAME"
(( WARN++ ))
}
# Restore mode.
chmod -- "${DB_MODES[$ID]}" "$PATHNAME" 2>/dev/null || {
warn "Failed to restore permissions: $PATHNAME"
(( WARN++ ))
}
# Restore {a,m}times (and ACLs on Linux).
if [[ "$PLATFORM" == "Linux" ]]; then
# BusyBox touch doesn't support nanosecond accuracy, so work around it.
FMT="%Y-%m-%d %H:%M:%S"
touch --help 2>&1 | grep BusyBox >/dev/null 2>&1 || {
FMT+=".%N"
}
touch -m -d "$(date -d "@${DB_MTIMES[$ID]}" +"$FMT" 2>/dev/null)" -- "$PATHNAME" 2>/dev/null || {
warn "Failed to restore mtime: $PATHNAME"
(( WARN++ ))
}
touch -a -d "$(date -d "@${DB_ATIMES[$ID]}" +"$FMT" 2>/dev/null)" -- "$PATHNAME" 2>/dev/null || {
warn "Failed to restore atime: $PATHNAME"
(( WARN++ ))
}
[[ "${DB_ACLS[$ID]}" != "-" ]] && {
printf "%s" "${DB_ACLS[$ID]}" | base64 -d 2>/dev/null | setfacl -M - -- "$PATHNAME" 2>/dev/null || {
warn "Failed to restore ACLs: $PATHNAME"
(( WARN++ ))
}
}
[[ "${DB_XATTRS[$ID]}" != "-" ]] && {
printf "%s" "${DB_XATTRS[$ID]}" | base64 -d 2>/dev/null | setfattr --restore=- 2>/dev/null || {
warn "Failed to restore xattrs: $PATHNAME"
(( WARN++ ))
}
}
elif [[ "$PLATFORM" == "Darwin" ]]; then
touch -m -d "$(date -j -r "${DB_MTIMES[$ID]%.*}" +"%Y-%m-%dT%H:%M:%S.${DB_MTIMES[$ID]#*.}")" -- "$PATHNAME" 2>/dev/null || {
warn "Failed to restore mtime: $PATHNAME"
(( WARN++ ))
}
touch -a -d "$(date -j -r "${DB_ATIMES[$ID]%.*}" +"%Y-%m-%dT%H:%M:%S.${DB_ATIMES[$ID]#*.}")" -- "$PATHNAME" 2>/dev/null || {
warn "Failed to restore atime: $PATHNAME"
(( WARN++ ))
}
fi
(( VERBOSE == 1 )) && (( WARN == PREV_WARN )) && log "$PATHNAME"
PREV_WARN="$WARN"
(( COUNT++ ))
done < <(printf "%s\\n" "${!DB_OWNERSHIPS[@]}")
if (( WARN == 0 )); then
log "$COUNT entries restored"
else
log "$COUNT entries at least partially restored (with $WARN warnings)"
fi
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.
case "$1" in
'-h'|'--h'|'--help')
show_help
exit 0
;;
'-v'|'--v'|'--verbose')
VERBOSE=1
shift
;;
'post-checkout'|'post-merge')
# Restore the path attributes from the database.
FUNC="restore_attributes"
shift
;;
'pre-commit')
# Store the path attributes into the database.
FUNC="store_attributes"
shift
;;
*)
printf "%s: %s: %s\\n" "${0##*/}" "invalid option" "$1" >&2
printf "%s: %s %s\\n" "Try" "${0##*/}" "--help" >&2
exit 1
;;
esac
# Sanity.
[[ -z "$FUNC" ]] && {
printf "%s: %s\\n" "${0##*/}" "missing argument" >&2
printf "%s: %s %s\\n" "Try" "${0##*/}" "--help" >&2
exit 1
}
# Run the appropriate function.
"$FUNC"
exit 0