Git Hooks: Automate Your Workflow
Git hooks are scripts that run automatically before or after Git events — committing, pushing, merging, and receiving pushes. They let you enforce code quality, run tests, check for secrets, and automate repetitive tasks. Hooks are stored in the .git/hooks/ directory of every repository.
How Hooks Work
A hook is an executable script placed in .git/hooks/ with a specific name. Git runs the script when the corresponding event occurs. If the script exits with a non-zero status, Git aborts the operation.
.git/hooks/
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
prepare-commit-msg.sample
push-to-checkout.sample
sendemail-validate.sample
update.sampleThe .sample extension prevents them from executing. To activate a hook, remove the .sample extension and make the file executable.
Client-Side Hooks
Client-side hooks run on your local machine. They are not pushed with the repository — each developer must set them up separately.
Pre-Commit Hook
The most popular hook. It runs before a commit message is created. Use it to:
- Check code formatting (Prettier, Black, gofmt)
- Run linting (ESLint, Pylint, golangci-lint)
- Check for large files or secrets
- Run unit tests for changed files
- Verify file naming conventions
#!/bin/bash
# .git/hooks/pre-commit
echo "Running linter..."
npm run lint
if [ $? -ne 0 ]; then
echo "Linting failed. Commit aborted."
exit 1
fi
echo "Running tests for staged files..."
npx jest --onlyChanged
if [ $? -ne 0 ]; then
echo "Tests failed. Commit aborted."
exit 1
fiPre-Commit with Staged Files Only
Lint only the files that are being committed:
#!/bin/bash
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "\.js$")
if [ -n "$STAGED_FILES" ]; then
echo "$STAGED_FILES" | xargs npx eslint
if [ $? -ne 0 ]; then
echo "ESLint found errors in staged files."
exit 1
fi
fiThis staged-only approach is significantly faster than running the linter on the entire codebase. For large projects, running ESLint on 5 changed files instead of 500 can mean the difference between a 2-second hook and a 30-second hook — making developers much less likely to use --no-verify.
Commit-Msg Hook
Validates the commit message format before the commit completes:
#!/bin/bash
# .git/hooks/commit-msg
# Enforce conventional commit format
# Format: type(scope): description
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\(.+\))?: .{1,72}$"
if ! grep -qE "$PATTERN" "$1"; then
echo "Error: Commit message must follow conventional commit format:"
echo " type(scope): description"
echo " Valid types: feat, fix, docs, style, refactor, test, chore, perf"
exit 1
fiPrepare-Commit-Msg Hook
Modifies the default commit message before the editor opens. Useful for automatically adding issue numbers:
#!/bin/bash
# .git/hooks/prepare-commit-msg
BRANCH_NAME=$(git symbolic-ref --short HEAD)
ISSUE_NUMBER=$(echo "$BRANCH_NAME" | grep -oP '(?<=issue-)\d+')
if [ -n "$ISSUE_NUMBER" ]; then
echo "Issue: #$ISSUE_NUMBER" >> "$1"
fiPre-Push Hook
Runs before a push. Use it for checks that take longer or require network access:
#!/bin/bash
# .git/hooks/pre-push
echo "Running full test suite..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
echo "Checking for merge conflicts..."
if git diff --check | grep -q "conflict"; then
echo "Merge conflict markers found. Push aborted."
exit 1
fiServer-Side Hooks
Server-side hooks run on the Git server when receiving pushes. They enforce policies across the entire team.
Pre-Receive Hook
Runs on the server before accepting a push. Can reject pushes based on branch names, file patterns, or commit messages:
#!/bin/bash
# hooks/pre-receive on the server
while read oldrev newrev refname; do
# Reject direct pushes to main
if [[ "$refname" =~ "refs/heads/main" ]]; then
echo "Error: Direct pushes to main are not allowed."
echo "Use pull requests instead."
exit 1
fi
# Check for large files (>5MB)
for commit in $(git rev-list $oldrev..$newrev); do
for file in $(git diff-tree --no-commit-id -r $commit); do
size=$(git cat-file -s $file)
if [ $size -gt 5242880 ]; then
echo "Error: File larger than 5MB found."
exit 1
fi
done
done
doneUpdate Hook
Similar to pre-receive but runs once per branch being updated:
#!/bin/bash
# hooks/update on the server
refname="$1"
oldrev="$2"
newrev="$3"
# Enforce branch naming
if [[ "$refname" =~ "refs/heads/" ]]; then
branchname="${refname#refs/heads/}"
if ! [[ "$branchname" =~ ^(feature|bugfix|hotfix|release)/ ]]; then
echo "Error: Branch name must start with feature/, bugfix/, hotfix/, or release/"
exit 1
fi
fiManaging Hooks with a Framework
Since hooks are not tracked by Git, teams typically use frameworks to share them.
Husky (JavaScript)
Husky manages Git hooks through npm:
npm install husky --save-dev
# package.json
{
"husky": {
"hooks": {
"pre-commit": "npm run lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-push": "npm test"
}
}
---pre-commit (Python)
The pre-commit framework works with any language:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=500']
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: blackpip install pre-commit
pre-commit install
# Run on all files
pre-commit run --all-filesReal-World Hook Setup
A typical mid-size web project might use the following hook configuration:
pre-commit (fast checks):
- ESLint on staged JavaScript files
- Prettier formatting check
- Detect private keys or credentials
- Check for large files (>1MB)
- Validate file naming conventions
commit-msg:
- Enforce Conventional Commits format (
feat:,fix:,chore:, etc.) - Limit subject line to 72 characters
- Ensure the body is separated by a blank line
pre-push (slower checks):
- Run full test suite
- Build the project
- Run security audit (
npm auditor similar) - Check for unresolved merge conflict markers
This layered approach ensures that fast checks catch issues immediately (pre-commit), while more expensive validations run only when pushing (pre-push). Developers never wait more than a few seconds for the pre-commit hook, making them less likely to use --no-verify.
Common Hook Patterns
Check for Secrets
#!/bin/bash
# pre-commit
if git diff --cached | grep -P '(password|secret|api.?key|token).*[''"]?[A-Za-z0-9_-]{20,}[''"]?' > /dev/null; then
echo "Warning: Possible secret detected in staged changes."
echo "Review the diff before committing."
exit 1
fiAuto-format Staged Files
A pre-commit hook that automatically formats staged files ensures consistent code style without manual effort:
#!/bin/bash
# pre-commit — auto-format staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|jsx|tsx|json|css|md)$')
if [ -n "$STAGED_FILES" ]; then
echo "$STAGED_FILES" | xargs npx prettier --write
echo "$STAGED_FILES" | xargs git add
fiThis hook runs Prettier on staged files, then re-stages the formatted versions. The developer never sees the formatting changes — they just happen automatically at commit time.
Enforce Branch Name Policy
#!/bin/bash
# pre-push
BRANCH=$(git symbolic-ref --short HEAD)
if [[ "$BRANCH" != "main" && "$BRANCH" != "develop" ]]; then
if ! echo "$BRANCH" | grep -qE "^(feature|bugfix|hotfix|chore)/"; then
echo "Branch name must start with feature/, bugfix/, hotfix/, or chore/"
exit 1
fi
fiMigrating from Shell Hooks to Pre-commit Framework
If your team currently uses custom shell scripts in .git/hooks/, migrating to the pre-commit framework involves these steps:
- Install pre-commit:
pip install pre-commit - Create
.pre-commit-config.yamlwith hooks that replace your shell scripts - Run
pre-commit installto set up the framework - Test by running
pre-commit run --all-files - Remove old shell scripts from
.git/hooks/ - Commit the
.pre-commit-config.yamlto your repository
The migration pays off quickly through automatic updates, cross-platform compatibility, and access to hundreds of community-maintained hooks.
Conclusion
Git hooks are a powerful automation tool that runs scripts at key points in the Git lifecycle. Client-side hooks (pre-commit, commit-msg, pre-push) enforce quality standards locally before code reaches the server. Server-side hooks (pre-receive, update) enforce policies across the entire team. Using a framework like pre-commit or Husky makes hooks easy to install and maintain. A well-designed hook system prevents common issues, enforces coding standards, and reduces the burden on code reviewers — catching formatting issues, secrets, and broken tests long before they reach a pull request.
Related: See our Git workflow strategies and Git branching strategy.
FAQ
Q: Can I write Git hooks in languages other than bash? A: Yes. Hooks can be any executable — Python, Ruby, Node.js, Go, or compiled binaries. The shebang line determines the interpreter.
Q: How do I distribute hooks to my team?
A: The most maintainable approach is the pre-commit framework with a .pre-commit-config.yaml file. Alternatively, use a .githooks/ directory with git config core.hooksPath .githooks.
Q: What is the difference between pre-commit and pre-push hooks?
A: pre-commit runs before each commit and should be fast (linting, formatting). pre-push runs before pushing and can run slower checks (full test suite, integration tests).
Q: How do I skip hooks temporarily?
A: Use git commit --no-verify to skip pre-commit and commit-msg hooks, or git push --no-verify to skip pre-push. Use these sparingly — they should be reserved for emergencies.
Q: Can hooks modify my files? A: Yes, hooks can modify files. For example, a pre-commit hook can auto-format code with Prettier or Black. Modified files will be included in the commit automatically.
Q: What happens if a hook takes too long? A: There is no timeout — Git waits for the hook to complete. Developers will find ways to bypass slow hooks, so keep them fast enough to run on every commit.