Learning Objectives
By the end of this lesson you will be able to:
- Write a
pre-commithook that blocks a commit on a failed check - Limit a hook to the staged files, so it stays fast
- Add a secret-scanning check to your own repository
- Keep the hook under the time budget that stops people disabling it
What Belongs in pre-commit
The hook runs on every commit, so the budget is small. Under about five seconds is the target; past that, people type --no-verify out of habit and the hook protects nothing.
Good candidates:
- A linter on the staged files
- A formatter check (or an auto-format)
- Secret scanning — API keys, private keys,
.envfiles - Debug leftovers:
console.log,debugger,.onlyin a test file - A handful of fast unit tests, if they really are fast
Wrong place: the full test suite, an end-to-end run, a build. Those belong in pre-push, or in CI.
A Working Hook
.git/hooks/pre-commit:
#!/usr/bin/env bash
set -euo pipefail
# Only the files being committed, and only the ones that still exist
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$STAGED" ] && exit 0
FAILED=0
# 1. Debug leftovers
if echo "$STAGED" | grep -qE '\.(ts|js)$'; then
if echo "$STAGED" | grep -E '\.(ts|js)$' | xargs grep -nE 'console\.log|debugger'; then
echo "❌ Debug statements found. Remove them or use --no-verify deliberately."
FAILED=1
fi
fi
# 2. Focused tests that would silently disable the rest of the suite
if echo "$STAGED" | grep -E '\.spec\.ts$' | xargs -r grep -nE '\b(describe|it|test)\.only'; then
echo "❌ .only found in a test file — this disables the rest of the suite."
FAILED=1
fi
# 3. Lint, staged files only
echo "$STAGED" | grep -E '\.(ts|js)$' | xargs -r npx eslint || FAILED=1
exit $FAILED
Then:
chmod +x .git/hooks/pre-commit
The .only check is the one worth stealing outright. A committed it.only silently skips every other test in the file, CI goes green, and a regression walks straight through. It is a genuinely nasty defect and a three-line hook eliminates it.
Checking Only the Staged Files
git diff --cached --name-only --diff-filter=ACM
This is the line that makes the hook fast and correct.
--cached means the index — exactly what is about to be committed, not everything you have edited. --diff-filter=ACM keeps Added, Copied and Modified files and drops Deleted ones, so the hook does not try to lint a file that no longer exists.
Linting the whole project instead is both slower and wrong: it fails on pre-existing problems in files you did not touch, which is the fastest possible way to teach a team to use --no-verify.
Scanning for Secrets
The highest-value check in the whole hook, because a committed credential is the one mistake in this trail that cannot be quietly undone:
if echo "$STAGED" | xargs -r grep -nEi \
'api[_-]?key|secret[_-]?key|password\s*=|BEGIN (RSA|OPENSSH) PRIVATE KEY'; then
echo "❌ Possible secret detected. Check the lines above."
echo " If this is a false positive, commit with --no-verify."
exit 1
fi
Crude, and it will occasionally fire on a variable named apiKey in a test fixture. That is the right trade: a false positive costs five seconds of reading, and a missed secret costs a credential rotation and an incident report.
Dedicated tools — gitleaks, detect-secrets — do this far better, and are worth adopting for a real project. This version needs nothing installed.
Auto-Formatting Instead of Blocking
A formatter can fix rather than complain:
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|js|json)$')
if [ -n "$FILES" ]; then
echo "$FILES" | xargs npx prettier --write
echo "$FILES" | xargs git add # re-stage the formatted result
fi
The git add is essential. Without it the files are formatted on disk but the old content is what gets committed, which produces a genuinely baffling diff.
Teams differ on this. Auto-formatting is smoother; blocking is more explicit about what changed. Either is defensible — but pick one and let the whole team run the same thing.
Keeping It Fast
Four techniques, in order of effect:
- Only staged files. Usually a 10× difference on its own.
- Skip work when nothing matches. Exit early if no relevant files are staged.
- Run checks in the cheapest order.
grepbefore a linter, a linter before tests. - Move anything slow to
pre-push. That is the next lesson.
Time it honestly:
time .git/hooks/pre-commit
If it is over five seconds, cut something. A hook people bypass is worse than no hook, because it creates the false belief that the check is running.
Pro Tip: When a hook blocks a commit, print the exact command to fix it —
npx prettier --write src/foo.ts, not "formatting error". The difference between a hook people appreciate and a hook people disable is almost entirely in the quality of its error messages.
Key Takeaways
pre-commitruns on every commit; keep it under about five seconds- Lint, formatting, secret scanning and debug-leftover checks belong here; test suites do not
git diff --cached --name-only --diff-filter=ACMlimits the hook to what is being committed- The
.onlycheck prevents a silently disabled test suite for three lines of code - Auto-formatting must re-stage with
git add, or the unformatted version is committed - Print the exact fix command; a hook that only says "failed" gets bypassed
- A bypassed hook is worse than none — it creates false confidence