#!/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,SC2329

# 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 PATHCOMPONENT PATHNAME XATTR
  local -A SEEN

  # 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 all paths leading up to the file to the database.
    while read -r -d '/' PATHCOMPONENT; do
      [[ ! -v SEEN['$PATHCOMPONENT'] ]] && {
        if add_db_entry "$PATHCOMPONENT"; then
          SEEN+=('$PATHCOMPONENT')
          (( ADD_COUNT++ ))
        else
          (( ERR_COUNT++ ))
        fi
      }
    done <<<"$PATHNAME"
    # Add the file itself 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 all paths leading up to the file to the database.
      while read -r -d '/' PATHCOMPONENT; do
        [[ ! -v SEEN['$PATHCOMPONENT'] ]] && {
          if add_db_entry "$PATHCOMPONENT"; then
            SEEN+=('$PATHCOMPONENT')
            (( ADD_COUNT++ ))
          else
            (( ERR_COUNT++ ))
          fi
        }
      done <<<"$NAME"
      # Add the file itself 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
