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.
Related Articles
Git
Advanced Git Hooks: Automation, Enforcement & CI Integration
Advanced Git hooks — shared hook frameworks, pre-receive server hooks, CI integration, branch policy enforcement, and debugging hook scripts.
Git
Git Hooks: Automate Your Workflow with Git Hooks
Learn Git hooks — pre-commit, pre-push, commit-msg hooks, automating linting/testing, custom hooks, and hook management tools.
CI/CD
CI/CD Guide: Continuous Integration and Deployment Pipeline
Learn continuous integration and deployment fundamentals — pipeline architecture, automation benefits, version control integration, and reliable software delivery workflows.
DevOps
CI/CD Pipeline: Tools and Best Practices
Learn CI/CD pipeline tools — GitHub Actions, GitLab CI, Jenkins, CircleCI, pipeline design, testing stages, and deployment automation.
DevOps
CI/CD Pipelines: A Complete Guide
Learn CI/CD pipelines — continuous integration, delivery, and deployment. Covers GitHub Actions, Jenkins, GitLab CI, and best practices.