Modular Git Hooks (Pick-and-Choose)
This guide sets up a small, modular Git hook system that lets you enable, disable, reorder, and stack as many individual hooks as you like — without rewriting one big script. Each check is a standalone file in a per-event directory (pre-commit.d/, pre-push.d/, …); a tiny dispatcher runs every executable file it finds.
-
Enable a hook — drop an executable script into the matching
*.d/directory. -
Disable a hook — delete it (or remove its execute bit with
chmod -x). -
Run several — add as many scripts as you want; they run in filename order.
The examples below include a TruffleHog secret scan in two flavors: a fast staged-only scan at pre-commit and a full outgoing-diff scan at pre-push. Both are optional drop-in modules that skip themselves if TruffleHog is not installed.
How it works
Git looks for a single file per event (e.g. pre-commit). We make that file a dispatcher that simply executes every script in a sibling <event>.d/ directory, and then the repository’s own hook for that event, if it has one. Because the dispatcher derives the event name from its own filename, one script serves every hook type via symlinks.
~/.git-hooks/
├── dispatch # the generic runner (one script)
├── pre-commit -> dispatch # symlink
├── pre-push -> dispatch # symlink
├── pre-commit.d/ # drop pre-commit checks here
│ ├── 10-merge-conflict-guard
│ ├── 20-build-artifact-guard
│ └── 30-trufflehog-staged-scan
└── pre-push.d/ # drop pre-push checks here
├── 10-protected-branch-guard
└── 20-trufflehog-full-scan
1. One-time setup
Point Git at a global hooks directory (applies to every repository) and create the layout:
mkdir -p ~/.git-hooks/pre-commit.d ~/.git-hooks/pre-push.d
git config --global core.hooksPath ~/.git-hooks
|
Setting |
Create the dispatcher at ~/.git-hooks/dispatch:
#!/usr/bin/env bash
#
# Generic Git hook dispatcher. Runs every executable file in <hook>.d/ in
# filename order, then the repository's own hook of the same name, if it has
# one: a global core.hooksPath otherwise hides .git/hooks (Git LFS's pre-push,
# for example). Aggregates exit codes: if any of them fails, the hook fails.
#
set -euo pipefail
hook="$(basename "$0")"
base="$(cd "$(dirname "$0")" && pwd)"
hookdir="${base}/${hook}.d"
# Some events deliver data on stdin (e.g. pre-push sends the refs being pushed).
# Save it to a file once, so each hook reads all of it, whether or not the
# hook before it read its input (a pipe would fail when one doesn't).
input=/dev/null
case "$hook" in
pre-push|pre-receive|update|post-receive|post-rewrite)
input="$(mktemp "${TMPDIR:-/tmp}/git-hook-input.XXXXXX")"
trap 'rm -f "$input"' EXIT
cat >"$input" || true ;;
esac
rc=0
if [ -d "$hookdir" ]; then
for module in "$hookdir"/*; do
[ -f "$module" ] && [ -x "$module" ] || continue
if ! "$module" "$@" <"$input"; then
echo "✖ hook failed: ${hook}.d/$(basename "$module")" >&2
rc=1
fi
done
fi
# The repository's own hook, unless it is this dispatcher.
gitdir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
own="${gitdir:+$gitdir/hooks/$hook}"
if [ -n "$own" ] && [ -f "$own" ] && [ -x "$own" ] && ! [ "$own" -ef "$0" ]; then
if ! "$own" "$@" <"$input"; then
echo "✖ hook failed: $own" >&2
rc=1
fi
fi
exit $rc
|
If you set up these hooks before November 2026, copy the dispatcher and the modules on this page again, or run the setup script, which replaces copies you haven’t changed and keeps backups. The current versions fix these problems in the earlier ones:
|
Make it executable and link the events you want to use:
chmod +x ~/.git-hooks/dispatch
ln -sf dispatch ~/.git-hooks/pre-commit
ln -sf dispatch ~/.git-hooks/pre-push
Also link the other common events, even with no modules of your own for them, so that each repository’s own hooks for them still run, such as Git LFS’s post-checkout and post-merge, or a commit-msg hook that checks for a Jira key. Without a link, Git skips them, because of the global core.hooksPath. The command leaves alone any of these you already have:
for h in applypatch-msg pre-applypatch post-applypatch prepare-commit-msg commit-msg post-commit \
pre-merge-commit pre-rebase post-checkout post-merge post-rewrite; do
[ -e ~/.git-hooks/$h ] || ln -s dispatch ~/.git-hooks/$h
done
|
The numeric filename prefixes ( |
Pre-commit modules
Merge-conflict marker guard
Blocks a commit that introduces leftover conflict markers (<<<<<<< / >>>>>>>) in staged changes. Save as ~/.git-hooks/pre-commit.d/10-merge-conflict-guard and chmod +x it.
#!/usr/bin/env bash
# Reject staged changes that add Git conflict markers.
set -euo pipefail
# Staged files (Added/Copied/Modified), NUL-safe for paths with spaces.
# --no-renames lists a renamed file under its new name instead of leaving it out.
files=()
while IFS= read -r -d '' f; do files+=("$f"); done \
< <(git diff --cached --name-only --no-renames --diff-filter=ACM -z)
[ ${#files[@]} -eq 0 ] && exit 0
# Look at added lines only ('^+') for the unambiguous markers.
# (======= is intentionally omitted to avoid Markdown/asciidoc false positives.)
hits="$(git diff --cached --no-renames -U0 --no-color -- "${files[@]}" \
| grep -nE '^\+(<<<<<<<|>>>>>>>)' || true)"
if [ -n "$hits" ]; then
echo "❌ Merge conflict markers found in staged changes:" >&2
echo "$hits" >&2
echo " Resolve the conflict before committing (or 'git commit --no-verify' to bypass)." >&2
exit 1
fi
exit 0
Build-artifact / log guard
Blocks accidentally staged build output and logs even when .gitignore is missing or stale. Save as ~/.git-hooks/pre-commit.d/20-build-artifact-guard and chmod +x it.
#!/usr/bin/env bash
# Reject staged build artifacts and log files.
set -euo pipefail
# Maven's target/ output and any .log file (skaffold.log, verify.log, ...), but not a
# source package that happens to be named target (src/main/java/.../target/).
# --no-renames lists a renamed file under its new name instead of leaving it out.
blocked="$(git diff --cached --name-only --no-renames --diff-filter=ACM \
| grep -E '(^|/)target/|\.log$' | grep -vE '(^|/)src/(.*/)?target/' || true)"
if [ -n "$blocked" ]; then
echo "❌ Refusing to commit build artifacts / logs:" >&2
echo "$blocked" | sed 's/^/ - /' >&2
echo " Unstage them ('git restore --staged <file>') and add them to .gitignore." >&2
echo " To bypass for one commit: git commit --no-verify" >&2
exit 1
fi
exit 0
Staged secret scan (TruffleHog)
Scans the changes you are about to commit for secrets (API keys, tokens, private keys, passwords, etc.) before they ever leave your machine. Requires TruffleHog (brew install trufflehog); if it is not installed the module warns and skips, so it is safe to add unconditionally. Save as ~/.git-hooks/pre-commit.d/30-trufflehog-staged-scan and chmod +x it.
|
This is a local safety net, not a replacement for server-side scanning (e.g. ScanHub) or for never putting real secrets in source. It is your first line of defense, caught before a secret ever leaves your machine. For deeper coverage, pair it with the full outgoing-diff scan in the pre-push section below. |
#!/usr/bin/env bash
# Scan staged changes for secrets with TruffleHog.
set -euo pipefail
# Git apps started from the Dock (Sourcetree, GitHub Desktop, Fork, ...) don't have
# Homebrew's folders on PATH, so look there too.
PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"
# If TruffleHog isn't installed, warn but don't block the commit.
if ! command -v trufflehog >/dev/null 2>&1; then
echo "warning: trufflehog not found on PATH; skipping secret scan." >&2
echo " install it with 'brew install trufflehog' to enable scanning." >&2
exit 0
fi
# Collect staged files that are Added, Copied, or Modified (skip deletions).
# --no-renames lists a renamed file under its new name instead of leaving it out.
# Uses NUL separators to handle paths with spaces.
staged_files=()
while IFS= read -r -d '' file; do
staged_files+=("$file")
done < <(git diff --cached --name-only --no-renames --diff-filter=ACM -z)
# Nothing to scan (e.g. a commit with only deletions).
[ ${#staged_files[@]} -eq 0 ] && exit 0
# Scan what will be committed: the staged version of each file, which differs
# from the one on disk after 'git add -p' or an edit made after 'git add'.
staged="$(mktemp -d "${TMPDIR:-/tmp}/staged-scan.XXXXXX")"
trap 'rm -rf "$staged"' EXIT
printf '%s\0' "${staged_files[@]}" | git checkout-index -z --stdin --prefix="$staged/"
echo "🔍 Scanning ${#staged_files[@]} staged file(s) for secrets with TruffleHog..."
# --fail returns exit code 183 if any results are found; any other non-zero code
# means the scan itself failed. All results are reported, including unverified
# ones: secrets TruffleHog can't check with their provider, such as private keys
# and passwords, are unverified. The pre-push scan reports fewer, since it covers
# whole branches, where old false positives would get in the way.
status=0
# The folder, not a list of files: thousands of staged paths would be too long for a
# command line, and a path starting with "-" would read as an option.
(cd "$staged" && trufflehog filesystem . \
--results=verified,unverified,unknown \
--fail \
--no-update) || status=$?
if [ "$status" -eq 183 ]; then
echo
echo "❌ Potential secret(s) detected in your staged changes (see above)." >&2
echo " - Remove the secret and use a vault/env var instead, or" >&2
echo " - If it is a false positive, add a 'trufflehog:ignore' comment on that line, or" >&2
echo " - To bypass for a single commit (use sparingly): git commit --no-verify" >&2
exit 1
elif [ "$status" -ne 0 ]; then
echo "❌ The secret scan didn't finish (trufflehog exit code $status; see above), so the commit was stopped." >&2
echo " Fix the problem, or bypass for a single commit (use sparingly): git commit --no-verify" >&2
exit 1
fi
echo "✅ No secrets detected in staged changes."
exit 0
Pre-push modules
|
|
Protected-branch guard
Refuses direct pushes to main (or master) and any Release/* branch — you merge those via pull request. Save as ~/.git-hooks/pre-push.d/10-protected-branch-guard and chmod +x it.
#!/usr/bin/env bash
# Block direct pushes to protected branches; open a PR instead.
set -euo pipefail
protected='^refs/heads/(main|master|Release/.*)$'
while read -r local_ref local_sha remote_ref remote_sha; do
[ -z "${remote_ref:-}" ] && continue
if [[ "$remote_ref" =~ $protected ]]; then
echo "❌ Direct pushes to '${remote_ref#refs/heads/}' are not allowed." >&2
echo " Push a feature branch (e.g. CKM-12345) and open a pull request." >&2
echo " To bypass (rarely needed): git push --no-verify" >&2
exit 1
fi
done
exit 0
Full-diff secret scan (TruffleHog)
Scans the commits each push sends for secrets before they leave your machine: for each branch you push, the commits the remote doesn’t have yet, from where the branch left the remote’s default branch. Your other branches aren’t scanned, and the first push of a new repository scans its whole history — complementing the staged-only pre-commit scan. Requires trufflehog (brew install trufflehog). Save as ~/.git-hooks/pre-push.d/20-trufflehog-full-scan and chmod +x it.
Earlier versions of this hook passed origin/main to TruffleHog by name. TruffleHog scans its own clone of the repository, where that name means your local main branch, which can be out of date or missing, so the old hook could scan nothing and still report no secrets. Versions before October 29, 2026 also ignored the branches being pushed: they scanned every local branch, so a secret on a branch you weren’t pushing could block the push, and they skipped the first push of a new repository. Replace an old copy with the one below. The setup script (version 1.6.0 or later) replaces it for you, with install or doctor --fix, if you haven’t changed it.
|
#!/usr/bin/env bash
# Scan outgoing commits for secrets with TruffleHog before pushing.
set -euo pipefail
# Git apps started from the Dock (Sourcetree, GitHub Desktop, Fork, ...) don't have
# Homebrew's folders on PATH, so look there too.
PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"
if ! command -v trufflehog >/dev/null 2>&1; then
echo "warning: trufflehog not found; skipping outgoing secret scan." >&2
exit 0
fi
# Git sends one line per ref being pushed: <local ref> <local commit> <remote ref> <remote commit>.
# Each commit is scanned from where the remote already has it, or from where it left the remote's
# default branch, so only what this push sends is scanned, not your other branches. With nothing to
# compare against, such as the first push of a new repository, its whole history is scanned.
remote="${1:-origin}"
default="$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null || true)"
if [ -z "$default" ] && git rev-parse --verify -q "${remote}/main" >/dev/null 2>&1; then
default="${remote}/main"
fi
repo_root="$(git rev-parse --show-toplevel)"
found=0
failed=0
while read -r local_ref local_sha remote_ref remote_sha; do
# A deleted branch sends nothing, and a tag is scanned as the commit it points to.
commit="$(git rev-parse -q --verify "${local_sha:-0}^{commit}" 2>/dev/null || true)"
case "${local_sha:-0}" in *[!0]*) [ -n "$commit" ] || continue ;; *) continue ;; esac
base=""
case "${remote_sha:-0}" in
*[!0]*) base="$(git rev-parse -q --verify "${remote_sha}^{commit}" 2>/dev/null || true)" ;;
esac
if [ -z "$base" ] && [ -n "$default" ]; then
base="$(git merge-base "$commit" "$default" 2>/dev/null || true)"
fi
[ "$base" != "$commit" ] || continue # nothing new to send
name="${local_ref#refs/heads/}"
# TruffleHog scans its own clone of the repository, where branch names point elsewhere,
# so it gets commit IDs.
status=0
if [ -n "$base" ]; then
echo "🔍 Scanning the commits ${name} adds for secrets..."
trufflehog git "file://${repo_root}" --branch "$commit" --since-commit "$base" \
--results=verified,unknown --fail --no-update || status=$?
else
echo "🔍 Scanning all of ${name}'s history for secrets (${remote} has nothing to compare it with)..."
trufflehog git "file://${repo_root}" --branch "$commit" \
--results=verified,unknown --fail --no-update || status=$?
fi
# --fail returns exit code 183 if any results are found; any other non-zero code
# means the scan itself failed.
if [ "$status" -eq 183 ]; then
found=1
elif [ "$status" -ne 0 ]; then
failed="$status"
fi
done
if [ "$found" -eq 1 ]; then
echo "❌ Potential secret(s) detected in outgoing commits (see above)." >&2
echo " Remove the secret (and rewrite history if already committed) before pushing." >&2
echo " To bypass (use sparingly): git push --no-verify" >&2
exit 1
elif [ "$failed" -ne 0 ]; then
echo "❌ The secret scan didn't finish (trufflehog exit code $failed; see above), so the push was stopped." >&2
echo " Fix the problem, or bypass (use sparingly): git push --no-verify" >&2
exit 1
fi
echo "✅ No secrets detected in outgoing commits."
exit 0
Enabling, disabling, and ordering
| Action | How |
|---|---|
Enable a hook |
Place an executable script in the matching |
Disable a hook |
Delete the file, or remove its execute bit: |
Add another of the same type |
Just add another file to the same |
Change run order |
Rename with a different numeric prefix ( |
Use a new event (e.g. |
|
Testing your hooks
# Pre-commit modules: stage something and attempt a commit.
printf '<<<<<<< HEAD\n' > conflict-test.txt
git add conflict-test.txt
git commit -m "test" # should be blocked by the conflict guard
git restore --staged conflict-test.txt && rm conflict-test.txt
# Pre-push modules: feed the hook what git sends for a push to main (nothing is pushed).
printf 'refs/heads/test 1111 refs/heads/main 2222\n' | ~/.git-hooks/pre-push origin url
echo $? # 1 means the protected-branch guard blocked it
Notes
-
Bypass: every hook can be skipped for a single command with
--no-verify(git commit --no-verify,git push --no-verify). Use it sparingly. -
Global vs per-repo: these examples are global (via
core.hooksPath). Project-specific checks (Checkstyle,service-lint, Swagger validation) are better seeded per-repo, in the repository’s own.git/hooks; the dispatcher runs those after the global modules. -
Keep modules small and fast:
pre-commitruns on every commit, so keep those checks quick; put slower checks (full builds, full-history scans) inpre-push.
TruffleHog tips
- Ignore a known false positive
-
Add a
trufflehog:ignorecomment on the same line as the flagged value, and TruffleHog will skip it. - Tune the signal
-
TruffleHog sorts what it finds into
verified(the provider confirmed the credential works),unknown(it couldn’t check, for example without network access) andunverified(the provider rejected it, or TruffleHog can’t check that kind of secret, such as a private key or a password). The staged scan reports all three (--results=verified,unverified,unknown), because a false positive there costs only a moment: fix it or add atrufflehog:ignorecomment. The pre-push scan covers whole branches, so it reports onlyverifiedandunknownresults, which keeps old false positives in the history from blocking pushes. - Exact staged content
-
The staged scan copies the staged version of each file into a temporary folder (
git checkout-index) and scans that, so it checks exactly what will be committed, even aftergit add -por an edit made aftergit add. - Keep TruffleHog up to date
-
Run
brew upgrade trufflehogperiodically to pick up new detectors, or let the setup script’supgradecommand do it along with the other NGSS tools. (The modules pass--no-updateso they never block your commit/push on an update check.)