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 core.hooksPath globally replaces the default per-repository .git/hooks lookup, so Git itself no longer runs a repository’s own hooks, such as the ones Git LFS installs. The dispatcher below runs them for you after its modules. A repository that sets its own core.hooksPath skips these global hooks entirely.

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:

  • The dispatcher dropped the last line of input (before October 2026), so the protected-branch guard didn’t block pushes to main.

  • The dispatcher failed pushes with more input than a pipe holds (roughly 480 branches or tags at once), and didn’t run each repository’s own hooks.

  • The pre-commit modules didn’t see renamed files.

  • The staged secret scan read the working tree instead of what is staged, and both scans reported a TruffleHog error as found secrets.

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 (10-, 20-, …) only control order; they are otherwise arbitrary. Modules run in lexical order, so use them when one check should run before another.

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

pre-push receives one line per ref being pushed on stdin in the form <local ref> <local sha> <remote ref> <remote sha>. The dispatcher captures that stdin and replays it to each module, so the scripts below can read it directly.

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 *.d/ directory (chmod +x).

Disable a hook

Delete the file, or remove its execute bit: chmod -x ~/.git-hooks/pre-commit.d/20-build-artifact-guard.

Add another of the same type

Just add another file to the same *.d/ directory — they all run.

Change run order

Rename with a different numeric prefix (05-… runs before 20-…).

Use a new event (e.g. commit-msg)

mkdir ~/.git-hooks/commit-msg.d, link ~/.git-hooks/commit-msg to dispatch if it isn’t already (see 1. One-time setup), then add modules.

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-commit runs on every commit, so keep those checks quick; put slower checks (full builds, full-history scans) in pre-push.

TruffleHog tips

Ignore a known false positive

Add a trufflehog:ignore comment 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) and unverified (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 a trufflehog:ignore comment. The pre-push scan covers whole branches, so it reports only verified and unknown results, 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 after git add -p or an edit made after git add.

Keep TruffleHog up to date

Run brew upgrade trufflehog periodically to pick up new detectors, or let the setup script’s upgrade command do it along with the other NGSS tools. (The modules pass --no-update so they never block your commit/push on an update check.)