Last active
April 6, 2026 11:34
-
-
Save Konfekt/d9e86763b0f3febd7b2f7ca589f6c482 to your computer and use it in GitHub Desktop.
use global git hooks as fallback to local hooks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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