Writing a custom local pre-commit hook Jump to heading
Most useful checks are already published as hook repositories, which is why the pre-commit framework is mostly a matter of listing pinned revisions. The exceptions are the rules that only make sense inside your project: a naming convention for migration files, a rule that every new API handler has a matching fixture, a check that a configuration key added in one file also appears in the example file next to it. Those belong in a repo: local hook β and getting the exit codes and filename handling right is what makes the difference between a hook people rely on and one they disable within a fortnight.
When to use this approach Jump to heading
- The rule references project-specific structure and would be meaningless in any other repository.
- You already have a script that Husky called inline and are migrating to the framework.
- You want the frameworkβs staged-file filtering and stashing behaviour rather than reimplementing them in a bare Git hook.
- The check is fast β under a second on a typical commit. Anything slower belongs in pre-push validation or in the pipeline.
- The rule must eventually be enforced on the server too, in which case write it as a standalone script now so the server-side mirror can call the same file.
Step 1 β Decide whether the rule belongs in a hook at all Jump to heading
Only the green path is this recipeβs subject. The other three are worth naming because a hook is a tempting place to put every rule, and each misplacement has a characteristic cost: slow commits, false positives on files the commit never touched, or automation arguing about something that needed a conversation.
Step 2 β Write the script to accept filenames as arguments Jump to heading
The framework appends matching staged paths to the command. Iterate over them; never scan the tree yourself.
#!/bin/sh
# scripts/check-migration-names.sh
# Every migration file must be named <UTC-timestamp>_<snake_case_description>.sql
# Usage: scripts/check-migration-names.sh <file> [<file> ...]
set -eu
status=0
pattern='^[0-9]\{14\}_[a-z0-9]\{1,\}\(_[a-z0-9]\{1,\}\)*\.sql$'
for file in "$@"; do
base=$(basename "$file")
if ! printf '%s\n' "$base" | grep -q "$pattern"; then
printf '%s: name does not match <YYYYMMDDHHMMSS>_<snake_case>.sql\n' "$file" >&2
printf ' example: 20260731093000_add_signing_key_table.sql\n' >&2
status=1
fi
done
exit "$status" What changed: the rule is now a standalone, testable script that takes files in and returns 0 or 1 β the same shape a server-side policy script needs, so it can be reused there later without modification.
chmod +x scripts/check-migration-names.sh
scripts/check-migration-names.sh db/migrations/20260731093000_add_table.sql; echo "exit=$?" # 0
scripts/check-migration-names.sh db/migrations/AddTable.sql; echo "exit=$?" # 1 Two habits keep this correct. Quoting "$file" survives paths containing spaces, which appear in every repository eventually. And collecting status rather than exiting on the first failure means a commit touching five bad files reports all five.
Step 3 β Declare it as a local hook Jump to heading
- repo: local
hooks:
- id: migration-names
name: migration file naming
entry: scripts/check-migration-names.sh
language: script # the entry is a committed, executable file
files: ^db/migrations/.*\.sql$
pass_filenames: true # default; stated here because it is the point What changed: the check now runs on exactly the staged migration files, with no dependency to install and nothing for a new contributor to set up.
pre-commit run migration-names --all-files --verbose
# Expect: the hook name, the files it received, and a pass The language choice deserves a moment. script runs the committed file directly and works on a fresh clone with nothing installed. system runs a command that must already exist on PATH β right for terraform fmt or docker, wrong for anything you can commit, because it turns a working hook into a support question.
Step 4 β Choose between checking and fixing Jump to heading
A hook may report, or it may correct. If it corrects, it must still fail.
#!/bin/sh
# scripts/sync-env-example.sh
# Every key in .env must also appear in .env.example (values are not copied).
set -eu
[ -f .env.example ] || exit 0
changed=0
# Keys present in .env but missing from .env.example
missing=$(comm -23 \
"$(mktemp_keys() { grep -oE '^[A-Z_]+=' "$1" | sort -u; }; mktemp_keys .env > /tmp/a; echo /tmp/a)" \
"$(grep -oE '^[A-Z_]+=' .env.example | sort -u > /tmp/b; echo /tmp/b)") || true
for key in $missing; do
printf '%s\n' "${key}changeme" >> .env.example
printf 'added %s to .env.example\n' "$key" >&2
changed=1
done
# A hook that modified files MUST exit non-zero: the fix is unstaged,
# and the developer needs to see it before it becomes part of the commit.
[ "$changed" -eq 0 ] || exit 1
exit 0 What changed: the hook now repairs the omission automatically, but refuses the commit so the repair is reviewed and staged deliberately rather than slipped in unseen.
echo 'NEW_TOKEN=secret' >> .env
pre-commit run sync-env-example --all-files
# Expect: "added NEW_TOKEN= to .env.example", exit 1
git diff .env.example # the fix is here, unstaged, for you to read SAFETY WARNING β a hook that reads
.envmust never copy values into a tracked file. The script above writes only key names with a placeholder. Before enabling any hook that touches secret-bearing files, run it against a copy and inspect the diff; a hook that commits a credential is a far more expensive incident than the missing key it was written to prevent. Pair it with the pre-push secret scan as a second line of defence.
Step 5 β Test it without making a commit Jump to heading
# Run one hook against the whole repository
pre-commit run migration-names --all-files
# Run it against specific paths only
pre-commit run migration-names --files db/migrations/20260731093000_add_table.sql
# See exactly which files the framework passed in
pre-commit run migration-names --all-files --verbose
# Run every hook the way a commit would, without committing
pre-commit run The --files form is the one to reach for while developing the hook: it exercises the real code path β argument handling, exit code, message formatting β without needing a commit to exist, and without the stash-and-restore cycle a real commit triggers.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
When should a hook rewrite files instead of just reporting? Jump to heading
Rewrite when the correction is unambiguous and mechanical β formatting, trailing whitespace, sorted imports. Report when there is any judgement involved, because a hook that silently changes semantics is far more alarming than one that refuses. A rewriting hook must still exit non-zero after making changes, so the developer stages the fix deliberately rather than committing something they have not seen.
How does the script know which files to look at? Jump to heading
The framework appends the matching staged paths to the entry command as arguments, so the script iterates over its positional parameters. Never scan the repository yourself: that makes the hook slow, and it reports problems in files the commit does not touch, which is the fastest way to get a hook disabled. Set pass_filenames: false only for genuinely whole-project checks.
Should a local hook use language: system or language: script? Jump to heading
Use language: script when the entry is a file committed to the repository and executable β the framework runs it directly and nothing needs to be installed. Use language: system when the entry invokes a tool that must already be on PATH, such as terraform or docker. The difference matters on a fresh clone: a script hook works immediately, a system hook fails until the tool is installed.
Related Jump to heading
- The pre-commit Framework for Polyglot Repositories β the parent guide covering pinned hooks, scoping, and the CI job that makes them binding.
- Migrating from Husky to the pre-commit Framework β where the project scripts that become local hooks usually come from.
- Mirroring Local Hook Checks in Server-Side Policy β how to reuse the same script as authoritative server-side enforcement.