Git Hooks: Automate Your Workflow with Git Hooks
Git hooks are scripts that run automatically at specific points in the Git workflow. They let you enforce policies, run checks, and automate tasks before or after commits, pushes, merges, and other Git events. Hooks are stored in the .git/hooks/ directory and are local to each repository — they are not pushed with the code.
How Hooks Work
Each hook is an executable script (bash, Python, Ruby, or any language) placed in .git/hooks/ with a specific name:
.git/hooks/
├── applypatch-msg
├── commit-msg
├── fsmonitor-watchman
├── post-commit
├── post-checkout
├── post-merge
├── post-rewrite
├── pre-applypatch
├── pre-commit
├── pre-merge-commit
├── pre-push
├── pre-rebase
├── pre-receive
├── prepare-commit-msg
├── push-to-checkout
├── receive-deny-current-branch
└── updateGit runs the hook script if it exists and is executable. If the script exits with a non-zero status, Git aborts the operation. This makes hooks a powerful enforcement mechanism — developers cannot bypass them without explicitly using --no-verify.
Common Client-Side Hooks
pre-commit
Runs before a commit is created. Use it to lint code, run tests, check for secrets, or enforce formatting.
#!/bin/bash
# .git/hooks/pre-commit
echo "Running linter..."
npx eslint src/
if [ $? -ne 0 ]; then
echo "Linting failed. Commit aborted."
exit 1
fi
echo "Running tests..."
npm test -- --watchAll=false --passWithNoTests
if [ $? -ne 0 ]; then
echo "Tests failed. Commit aborted."
exit 1
fi
# Prevent committing to main branch
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "main" ]; then
echo "Direct commits to main are not allowed."
exit 1
ficommit-msg
Validates the commit message format before the commit completes:
#!/bin/bash
# .git/hooks/commit-msg
# Enforce conventional commit format
pattern="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+"
commit_message=$(cat "$1")
if ! [[ "$commit_message" =~ $pattern ]]; then
echo "Error: Commit message must follow conventional commit format:"
echo " <type>(<scope>): <description>"
echo " Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build"
exit 1
fi
# Enforce max line length
while IFS= read -r line; do
if [ ${#line} -gt 72 ]; then
echo "Error: Commit message lines must be under 72 characters."
echo "Line: $line"
exit 1
fi
done < "$1"pre-push
Runs before a push is sent to the remote. Use it for final validation or blocking pushes to protected branches:
#!/bin/bash
# .git/hooks/pre-push
# Block force pushes
if [ "$GIT_PUSH_FORCE" = "true" ]; then
echo "Force pushes are not allowed."
exit 1
fi
# Run integration tests before push
echo "Running integration tests..."
npm run test:integration
if [ $? -ne 0 ]; then
echo "Integration tests failed. Push aborted."
exit 1
fiprepare-commit-msg
Modifies the commit message before the editor opens. Useful for adding issue numbers or templates:
#!/bin/bash
# .git/hooks/prepare-commit-msg
# Prepend issue number from branch name to commit message
branch=$(git rev-parse --abbrev-ref HEAD)
issue=$(echo "$branch" | grep -oP '(?<=feature/)\d+' || echo "")
if [ -n "$issue" ]; then
sed -i "1s/^/[$issue] /" "$1"
fiCommon Server-Side Hooks
Server-side hooks run on the remote repository (e.g., GitHub, GitLab, or a self-hosted Git server). They enforce policies across all contributors.
pre-receive
Runs on the server before any reference is updated:
#!/bin/bash
# hooks/pre-receive
while read oldrev newrev refname; do
# Block tags that don't follow semver
if [[ $refname =~ ^refs/tags/ ]]; then
if ! [[ $refname =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Tags must follow semver format (v1.2.3)"
exit 1
fi
fi
doneupdate
Similar to pre-receive but runs once per reference being updated:
#!/bin/bash
# hooks/update
refname="$1"
newrev="$3"
# Enforce signed commits
if ! git verify-commit "$newrev" &>/dev/null; then
echo "Error: Commit $newrev is not signed"
exit 1
fiManaging Hooks
Making Hooks Executable
chmod +x .git/hooks/pre-commit
chmod +x .git/hooks/commit-msgBypassing Hooks
git commit --no-verify # Skip pre-commit and commit-msg
git push --no-verify # Skip pre-pushUse these sparingly — they should be reserved for emergencies.
Pre-commit Framework
The pre-commit framework provides a standardized way to manage and share hooks. It uses a configuration file and installs hooks from a curated collection.
Installation
pip install pre-commitConfiguration
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: https://github.com/psf/black
rev: 24.4.0
hooks:
- id: black
- repo: https://github.com/commitizen-tools/commitizen
rev: v3.27.0
hooks:
- id: commitizen
stages: [commit-msg]
- repo: https://github.com/golangci/golangci-lint
rev: v1.58.0
hooks:
- id: golangci-lintInstall
pre-commit install # Install pre-commit hook
pre-commit install --hook-type commit-msg # Install commit-msg hook
pre-commit run --all-files # Run all hooks on all files
pre-commit autoupdate # Update hook versionsThe pre-commit framework solves the distribution problem that has historically plagued Git hooks. Instead of every developer manually copying hook scripts, a single config file checked into the repository defines exactly which hooks run. The framework downloads and manages hook versions automatically.
Custom Hook Templates
Share hooks across your team by storing them in a directory and configuring Git to use them:
mkdir -p .githooks
cp pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooksCommit the .githooks/ directory and every clone will use the same hooks automatically. This approach works well for teams that cannot or do not want to use the pre-commit framework.
Server-Side Hook Deployment
Deploying server-side hooks requires access to the Git server’s filesystem. For GitHub and GitLab, use their built-in protected branch rules and CI/CD integrations instead. For self-hosted Git servers, place hooks in the bare repository’s hooks/ directory:
# For a bare repository at /srv/git/project.git
cp pre-receive /srv/git/project.git/hooks/pre-receive
chmod +x /srv/git/project.git/hooks/pre-receiveTest server-side hooks by pushing to a test branch before enforcing them on main.
Hook Debugging Tips
Debugging hooks can be challenging because they run in a non-interactive environment. Here are practical techniques:
# Enable verbose output
set -x # at the top of your hook script
# Redirect output to a log file for inspection
exec 1>/tmp/hook-debug.log 2>&1
# Check environment variables available to hooks
echo "GIT_DIR=$GIT_DIR" >> /tmp/hook-debug.log
echo "GIT_INDEX_FILE=$GIT_INDEX_FILE" >> /tmp/hook-debug.logCommon hook debugging steps:
- Check that the hook file is executable (
ls -la .git/hooks/pre-commit) - Run the hook manually:
.git/hooks/pre-commit - Check the shebang line matches the interpreter you expect
- Test with
git commit --no-verifyto confirm the hook is what blocks the operation
Best Practices
- Keep hooks fast — developers will bypass slow hooks
- Use stage-specific hooks — run fast checks in pre-commit, slower checks in pre-push
- Don’t modify committed files — use
git stashif you need to make changes - Provide clear error messages — developers need to know why a hook failed
- Use the pre-commit framework — it handles installation, updates, and cross-platform compatibility
- Store hooks in version control — use
.githooks/or ahooks/directory - Document your hooks — explain what each hook does in your project README
Hook Security Considerations
Hooks that modify files or run external commands can be a security vector. The pre-commit framework mitigates this by pinning hook versions to specific commits rather than branch heads. If you write custom hooks, avoid downloading and executing code from the internet. For team environments, use the pre-commit framework’s verified hooks or maintain your own curated hook repository.
Conclusion
Git hooks automate your development workflow by running scripts at key points in the Git lifecycle. Use pre-commit for fast validation (linting, formatting), commit-msg for message format enforcement, and pre-push for integration tests. The pre-commit framework simplifies hook management and provides access to hundreds of community-maintained hooks. With a well-designed hook system, your team can catch issues before they reach code review, enforce consistent coding standards, and reduce the burden on human reviewers.
Related: See our Git workflow strategies and Git branching strategy.
FAQ
Q: Are Git hooks pushed with the repository?
A: No. Hooks in .git/hooks/ are local and not tracked by Git. To share hooks, use a .githooks/ directory with core.hooksPath or use the pre-commit framework.
Q: What happens when a hook fails?
A: Git aborts the operation. For example, if pre-commit exits with a non-zero status, the commit is not created. You can bypass hooks with --no-verify.
Q: What languages can I use for Git hooks? A: Any executable language — bash, Python, Ruby, Node.js, Go binaries, or compiled C programs. The hook file just needs to be executable.
Q: How do I debug a failing hook?
A: Add set -x at the top of your bash hook script to enable verbose output. You can also run the hook manually: .git/hooks/pre-commit.
Q: Can I have multiple hooks for the same event? A: Not directly — there is only one script per event name. However, the pre-commit framework runs multiple hooks from within the single pre-commit script. Alternatively, your hook script can call multiple sub-scripts.
Q: What is the difference between client-side and server-side hooks? A: Client-side hooks run on each developer’s machine (pre-commit, pre-push). Server-side hooks run on the Git server (pre-receive, update) and enforce policies across all contributors.