Testing Git hooks before sharing them Jump to heading

A hook change reaches every developer the next time they install dependencies. If it is broken, it blocks everyone’s commits at once — or, worse, passes silently while doing nothing. Hooks are code, but they are rarely tested like code: someone edits .husky/pre-commit, commits once to see it work on their machine, and pushes. The failure appears on a colleague’s Mac with a different shell, or on a commit with a file name containing a space, or on a branch with no upstream. Testing hooks does not need special tools. A hook is a script with known inputs — arguments, standard input, the repository state — so it can be exercised in a throwaway repository with exactly those inputs. This page builds a small test setup that runs locally and in CI, within local hook configuration with Husky.

When to use this approach Jump to heading

  • Your repository’s hooks contain logic beyond a single tool call — branching, parsing, conditions.
  • A hook change once broke everyone’s commits, or silently stopped checking anything.
  • Contributors use different operating systems or shells.
  • You maintain server-side hooks too; the same approach is applied there in testing server-side hooks safely.

Step 1 — Move logic out of the hook file into a script Jump to heading

Keep the files in .husky/ as one-line wrappers that call scripts in a testable location. The wrapper is trivial; the script receives explicit arguments and can be run without Git calling it.

# .husky/commit-msg — wrapper only
sh scripts/hooks/check-commit-msg.sh "$1"
#!/bin/sh
# scripts/hooks/check-commit-msg.sh <message-file>
set -eu
msg=$(sed '/^#/d' "$1" | head -1)
case "$msg" in
  Merge*|Revert*|fixup!*|squash!*) exit 0 ;;
esac
echo "$msg" | grep -Eq '^(feat|fix|chore|docs|refactor|test|perf)(\([a-z0-9-]+\))?!?: .{3,}' || {
  echo "commit message must follow 'type(scope): summary' — got: $msg" >&2
  exit 1
}
Logic in the hook file against logic in a scriptLogic written directly in a hook file can only be exercised by making real commits. A thin wrapper that calls a script with explicit arguments lets the script be run directly with fixture files, tested in isolation and reused by CI.Logic in .husky/hookWrapper + scriptrun without gitnoyestest with fixturesawkwardeasyreuse in CIcopy-pastecall the scriptthe wrapper becomes too simple to break; the script becomes easy to test

Step 2 — Test the script directly with fixture inputs Jump to heading

With logic in a script, most tests need no Git at all: write fixture message files and check the exit codes.

#!/bin/sh
# scripts/hooks/test-check-commit-msg.sh
set -u
pass=0; fail=0
check() {   # check <expected-exit> <message>
  f=$(mktemp); printf '%s\n' "$2" > "$f"
  sh scripts/hooks/check-commit-msg.sh "$f" >/dev/null 2>&1; got=$?
  if [ "$got" -eq "$1" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: expected $1, got $got for: $2"; fi
  rm -f "$f"
}
check 0 "feat(export): schedule CSV exports"
check 0 "fix!: drop legacy discount field"
check 0 "Merge branch 'main' into feature/x"
check 1 "updated stuff"
check 1 "feat: x"
check 1 "FEAT(export): uppercase type"
echo "$pass passed, $fail failed"; [ "$fail" -eq 0 ]
sh scripts/hooks/test-check-commit-msg.sh

Step 3 — Test the hook end to end in a throwaway repository Jump to heading

Some behaviour only appears when Git calls the hook: the working directory, environment variables, staged state. Create a scratch repository, install the hooks there, and drive real commits.

#!/bin/sh
# scripts/hooks/test-e2e.sh — exercise hooks through real git commands
set -eu
root=$(pwd); tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
git -C "$tmp" init -q
git -C "$tmp" config user.email [email protected]; git -C "$tmp" config user.name test
cp -r "$root/.husky" "$root/scripts" "$tmp/"
git -C "$tmp" config core.hooksPath .husky
mkdir -p "$tmp/.husky/_" 2>/dev/null || true

# a commit with a good message must succeed
git -C "$tmp" commit -q --allow-empty -m "chore: e2e good message"
# a commit with a bad message must fail
if git -C "$tmp" commit -q --allow-empty -m "bad message" 2>/dev/null; then
  echo "FAIL: bad message was accepted"; exit 1
fi
# file names with spaces must not break pre-commit
printf 'x\n' > "$tmp/a file with spaces.md"; git -C "$tmp" add -A
git -C "$tmp" commit -q -m "docs: file with spaces" && echo "e2e ok"
Two layers of hook testsUnit tests run the hook script directly against fixture inputs and check exit codes, without Git. End-to-end tests create a throwaway repository, point it at the hooks, and make real commits with good and bad inputs, including awkward file names.Unit testsscript + fixturesScratch repogit init in tmpInstall hookscore.hooksPathReal commitsgood, bad, edgeAssertaccepted / rejectedunit tests catch logic errors; end-to-end tests catch how Git actually calls the hook

Step 4 — Check portability Jump to heading

Hooks run under /bin/sh, which is dash on Debian and Ubuntu, bash in POSIX mode on macOS, and Git for Windows’ bundled shell on Windows. Bash-only syntax that works on one machine fails on another. Lint hook scripts with ShellCheck in POSIX mode.

shellcheck --shell=sh .husky/* scripts/hooks/*.sh
# Run the tests under dash to catch bashisms
dash scripts/hooks/test-check-commit-msg.sh

Common traps include [[ ]], arrays, echo -e, process substitution and grep -P, none of which are POSIX.

Step 5 — Run the tests in CI on every hook change Jump to heading

Run hook tests whenever hook files or scripts change, on each operating system your team uses.

on:
  pull_request:
    paths: [".husky/**", "scripts/hooks/**"]
jobs:
  hook-tests:
    strategy: { matrix: { os: [ubuntu-latest, macos-latest, windows-latest] } }
    runs-on: ${{ matrix.os }}
    defaults: { run: { shell: sh } }
    steps:
      - uses: actions/checkout@v4
      - run: sh scripts/hooks/test-check-commit-msg.sh
      - run: sh scripts/hooks/test-e2e.sh
Where hook bugs were caughtAn illustrative year of hook changes in one team. Before testing, most hook bugs were found by teammates whose commits were blocked. After adding unit, end-to-end and cross-platform tests in CI, almost all were caught before merging.hook bugs per year by where they were found (illustrative)by teammates, before9by tests, before0by teammates, after1by tests, after8the Windows job alone caught three bashisms in the first quarter

Step 6 — Roll out risky hook changes gradually Jump to heading

For a hook change that could block commits widely — a new mandatory check — ship it in warn mode first: print the problem and exit 0, controlled by an environment variable. Switch to enforcing after a week of clean warnings, the same pattern as rolling out signature enforcement in warn-only mode.

# in the hook script
mode=${HOOK_MODE:-warn}
if ! check_passes; then
  echo "hook check failed: ..." >&2
  [ "$mode" = enforce ] && exit 1
fi
exit 0

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is there a framework for testing hooks? Jump to heading

Shell testing frameworks such as Bats work well and give nicer output than hand-written assertions. The plain-shell approach above has no dependencies, which suits small hook suites.

How do I test pre-push hooks, which read standard input? Jump to heading

Pipe the ref lines into the script exactly as Git would: printf 'refs/heads/main %s refs/heads/main %s\n' "$local" "$remote" | sh scripts/hooks/pre-push.sh origin url. The format is described in finding the new commits in a pre-push hook.

Should hook tests run on every pull request? Jump to heading

Only when hook files change, using a path filter. Running them on every pull request costs little, but tying them to hook changes keeps the signal clear.