Skip to content

Instantly share code, notes, and snippets.

@svandragt
Last active July 27, 2026 16:10
Show Gist options
  • Select an option

  • Save svandragt/4ca7ae0f07223b6cbec22d1f248273da to your computer and use it in GitHub Desktop.

Select an option

Save svandragt/4ca7ae0f07223b6cbec22d1f248273da to your computer and use it in GitHub Desktop.
Shared pre-push hook with npm lint and repository hook support

Shared pre-push hook

This global Git hook:

  • runs npm run lint when a repository defines an npm lint script;
  • runs composer lint:pre-push when available, otherwise composer lint;
  • calls an executable .repo-hooks.sh from the repository root;
  • falls back to the older repo-hooks.sh filename.

Install

Save pre-push to your global Git hooks directory, then run:

chmod +x ~/.config/git/hooks/pre-push
git config --global core.hooksPath ~/.config/git/hooks

Composer repositories can define lint:pre-push when linting needs access to Git's pre-push ref data. Repositories can also provide other pre-push behavior in an executable .repo-hooks.sh file.

#!/usr/bin/env bash
# Bash settings (fail fast, but with helpful diagnostics)
set -o errexit
set -o nounset
set -o pipefail
# --- Debug helpers -----------------------------------------------------------
DEBUG="${DEBUG:-0}"
log() { printf '%s\n' "$*"; }
debug() { if [[ "$DEBUG" == "1" ]]; then printf '[debug] %s\n' "$*"; fi }
die() {
printf '[pre-push] ERROR: %s\n' "$*" >&2
exit 1
}
on_error() {
local exit_code="$?"
local line_no="${BASH_LINENO[0]:-?}"
local cmd="${BASH_COMMAND:-?}"
printf '[pre-push] FAILED (exit=%s) at line %s: %s\n' "$exit_code" "$line_no" "$cmd" >&2
exit "$exit_code"
}
trap on_error ERR
# Print some context when debugging is enabled
if [[ "$DEBUG" == "1" ]]; then
set -x
log ">>> Debug enabled (DEBUG=1)"
log ">>> Shell: ${BASH:-unknown}"
log ">>> PWD: $(pwd)"
log ">>> Git: $(command -v git || true)"
git --version || true
log ">>> Hook: $0"
fi
# --- Assumption checks -------------------------------------------------------
# Ensure we're in a git repo (hooks normally are, but good to validate).
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
[[ -n "$git_root" ]] || die "Not inside a Git repository (git rev-parse failed)."
debug "git_root=$git_root"
cd "$git_root"
push_input="$(mktemp)"
trap 'rm -f "$push_input"' EXIT
cat > "$push_input"
# Choose npm runner (plain npm or ddev npm) using an array (handles spaces safely)
NPM=(npm)
if [[ -d ".ddev" ]]; then
log ">>> Detected .ddev/ (will try to use ddev npm)"
if command -v ddev >/dev/null 2>&1; then
NPM=(ddev npm)
else
die "Found .ddev/ but 'ddev' is not on PATH. Install ddev or remove/disable .ddev usage."
fi
fi
# --- Lint before push --------------------------------------------------------
if [[ -f "package.json" ]]; then
log ">>> package.json found"
# Verify the chosen command exists
if [[ "${NPM[0]}" == "npm" ]]; then
command -v npm >/dev/null 2>&1 || die "'npm' not found on PATH."
else
# NPM=(ddev npm) => check ddev exists (already done) and that ddev can run npm
debug "Checking 'ddev npm' availability..."
ddev version >/dev/null 2>&1 || die "'ddev' is present but not working (ddev version failed)."
fi
# Some npm versions return non-zero for "run --list" depending on project; don’t fail the whole push on that.
# We still want output for debugging.
npm_list_output="$("${NPM[@]}" run --list 2>&1 || true)"
debug "npm run --list output:"
debug "$npm_list_output"
if grep -Eq '(^|[[:space:]])lint([[:space:]]|$)' <<<"$npm_list_output"; then
log ">>> Lint script detected, running lint..."
"${NPM[@]}" run lint
else
log ">>> No lint script detected; skipping."
fi
else
log ">>> No package.json; skipping lint."
fi
# --- Composer lint -----------------------------------------------------------
if [[ -f "composer.json" ]]; then
log ">>> composer.json found"
if command -v composer >/dev/null 2>&1; then
COMPOSER=(composer)
elif command -v devbox >/dev/null 2>&1; then
COMPOSER=(devbox run -- composer)
else
die "Found composer.json but neither 'composer' nor 'devbox' is on PATH."
fi
composer_list_output="$("${COMPOSER[@]}" run-script --list --no-ansi 2>&1 || true)"
debug "composer run-script --list output:"
debug "$composer_list_output"
if grep -Eq '(^|[[:space:]])lint:pre-push([[:space:]]|$)' <<<"$composer_list_output"; then
log ">>> Composer pre-push lint script detected, running lint:pre-push..."
PRE_PUSH_INPUT="$push_input" "${COMPOSER[@]}" lint:pre-push
elif grep -Eq '(^|[[:space:]])lint([[:space:]]|$)' <<<"$composer_list_output"; then
log ">>> Composer lint script detected, running lint..."
"${COMPOSER[@]}" lint < "$push_input"
else
log ">>> No Composer lint script detected; skipping."
fi
else
log ">>> No composer.json; skipping Composer lint."
fi
# --- Repo hooks --------------------------------------------------------------
log ">>> Calling repo hooks..."
script="$(basename "$0")"
repo_hooks="$git_root/.repo-hooks.sh"
if [[ ! -e "$repo_hooks" ]]; then
repo_hooks="$git_root/repo-hooks.sh"
fi
if [[ -x "$repo_hooks" ]]; then
"$repo_hooks" "$script" < "$push_input"
elif [[ -f "$repo_hooks" ]]; then
die "Found $(basename "$repo_hooks") but it is not executable. Run: chmod +x "$repo_hooks""
else
log ">>> No repository hook found; skipping."
fi
log ">>> pre-push OK"
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment