Skip to content

Instantly share code, notes, and snippets.

@Konfekt
Last active April 6, 2026 11:34
Show Gist options
  • Select an option

  • Save Konfekt/d9e86763b0f3febd7b2f7ca589f6c482 to your computer and use it in GitHub Desktop.

Select an option

Save Konfekt/d9e86763b0f3febd7b2f7ca589f6c482 to your computer and use it in GitHub Desktop.
use global git hooks as fallback to local hooks
#!/usr/bin/env bash
#
# This script is meant to be put into a directory pointed to by core.hooksPath
# in Git 2.9.
# Then for each hook you want to support, create a directory "hookname.d" and
# a symlink "hookname -> stub", and put all scripts for that hook into the
# hookname.d directory.
#
# - ./HOOKNAME.d/*
# - $GIT_CUSTOM_HOOKS_DIR/HOOKNAME.d/*
# - $GIT_DIR/hooks/HOOKNAME.d/*
# - $GIT_DIR/hooks/HOOKNAME
#
# The hook-specific scripts found in those directories will be merged and
# executed in alphabetic order, with hooks in higher-priority directories
# overriding hooks in lower-priority directories.
#
# In addition, if $GIT_DIR/hooks contains normal hook scripts (i.e. scripts
# called "hookname" instead of scripts in "hookname.d" directories), they will
# be executed first.
# Old Bash on macOS doesn't support some features used here
if (( BASH_VERSINFO[0] < 5 )); then
hookscript="$GIT_DIR/hooks/$HOOKNAME"
if [[ -f "$hookscript" && -x "$hookscript" ]]; then
exec "$hookscript" "$@"
fi
exit 0
fi
set -eEu -o pipefail
shopt -s inherit_errexit
IFS=$'\n\t'
PS4="$BASH_SOURCE:"'+\t '
error_handler() { echo "Error: Line ${1} exited with status ${2}"; }
trap 'error_handler ${LINENO} $?' ERR
[[ "${TRACE:-0}" == "1" ]] && set -x
STDIN=$(cat)
# Save GIT_DIR before unsetting env vars, because during clone there is no
# .git directory yet, so `git rev-parse --git-dir` won't work; the only
# source of truth is the GIT_DIR that Git passed to the hook.
saved_git_dir="${GIT_DIR:-}"
saved_git_work_tree="${GIT_WORK_TREE:-}"
# Unset existing Git vars and set GIT_DIR explicitly, as per
# https://github.com/git/git/commit/772f8ff826fcb15cba94bfd8f23eb0917f3e9edc
# https://public-inbox.org/git/20180826004150.GA31168@sigill.intra.peff.net/t/
# but preserve GIT_INDEX_FILE because of
# https://github.com/j178/prek/blob/4406340db0fa6b99559dc145d5c787af110d5a8c/crates/prek/src/git.rs#L42
# shellcheck disable=SC2046
unset $(git rev-parse --local-env-vars | grep -v GIT_INDEX_FILE || true)
# Restore GIT_DIR: prefer the one Git passed to the hook (e.g. during clone,
# or in a linked worktree where it points to .git/worktrees/<name>).
# Fall back to `git rev-parse --git-dir` only if Git didn't set one.
if [[ -n "$saved_git_dir" ]]; then
GIT_DIR="$saved_git_dir"
else
# Temporarily restore GIT_WORK_TREE so git rev-parse can find the git dir
[[ -n "$saved_git_work_tree" ]] && export GIT_WORK_TREE="$saved_git_work_tree"
if GIT_DIR=$(git rev-parse --git-dir 2>/dev/null); then
: # success
else
echo "multihook: unable to determine GIT_DIR" >&2
exit 1
fi
fi
export GIT_DIR
# Restore GIT_WORK_TREE if Git originally set it (e.g. in a linked worktree)
if [[ -n "$saved_git_work_tree" ]]; then
export GIT_WORK_TREE="$saved_git_work_tree"
fi
unset saved_git_dir saved_git_work_tree
# Array of the supported hook directories in ascending order of priority
declare -a HOOKDIRS
HOOKDIRS+=("$(cd "$(dirname "$0")" && pwd)")
[[ -n "${GIT_CUSTOM_HOOKS_DIR:-}" ]] && HOOKDIRS+=("$GIT_CUSTOM_HOOKS_DIR")
HOOKDIRS+=("$GIT_DIR/hooks")
# The Git name of the hook to execute
HOOKNAME=$(basename "$0")
# Execute a normal hook first if it exists in the local Git dir
hookscript="$GIT_DIR/hooks/$HOOKNAME"
if [[ -f "$hookscript" && -x "$hookscript" ]]; then
echo "$STDIN" | "$hookscript" "$@" || exit $?
fi
# Associative array of the hook scripts to run
# - Key is the basename of the file
# - Value is the full path to the file
declare -A TO_RUN
# Assemble the array of scripts to run. Since the keys are the basenames of
# the scripts, scripts with the same name in higher-priority directories will
# override scripts in lower-priority directories. This allows replacing or
# disabling standard hooks without explicit support from the scripts.
for dir in "${HOOKDIRS[@]}"; do
hookdir="${dir}/${HOOKNAME}.d"
if [[ -d "$hookdir" ]]; then
for hook in "$hookdir"/*; do
hookname="$(basename "$hook")"
TO_RUN[$hookname]="$hook"
done
fi
done
# Iterate over the script array in alphabetic order, running all the scripts
# that have the executable bit set.
for key in "${!TO_RUN[@]}"; do
echo "$key"
done | sort | while read -r hookname; do
hookscript="${TO_RUN["$hookname"]}"
if [[ -f "$hookscript" && -x "$hookscript" ]]; then
echo "$STDIN" | "$hookscript" "$@" || exit $?
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment