Git Hooks for Automation: Pre-commit, Pre-push, and Husky
Automate code quality checks with Git hooks. Set up pre-commit linting, pre-push testing, commit message validation, and manage hooks with Husky across teams.
What you'll learn
- ✓How Git hooks work and where they live
- ✓Writing pre-commit hooks for linting and formatting
- ✓Enforcing commit message conventions with commit-msg hooks
- ✓Running tests before push with pre-push hooks
- ✓Managing hooks across teams with Husky and lint-staged
Prerequisites
- •Basic Git workflow (add, commit, push)
- •Familiarity with shell scripting basics
What Are Git Hooks
Git hooks are scripts that run automatically at specific points in the Git workflow. They live in the .git/hooks/ directory of every repository. When you initialize a repo, Git creates sample hooks with a .sample extension. Remove the .sample suffix and make them executable to activate them.
ls .git/hooks/
# applypatch-msg.sample pre-commit.sample
# commit-msg.sample pre-push.sample
# post-update.sample pre-rebase.sample
# pre-applypatch.sample prepare-commit-msg.sample
Hooks run locally. They are not pushed with the repository, which is both a feature (you can customize your own) and a problem (team consistency requires tooling like Husky).
git add -> git commit -> git push
| | |
| pre-commit | pre-push |
| commit-msg | |
| post-commit | post-push | Pre-Commit Hook: Catch Issues Before They Enter History
The pre-commit hook runs after you type git commit but before the commit is recorded. If the hook exits with a non-zero code, the commit is aborted.
Basic linting hook
#!/bin/sh
# .git/hooks/pre-commit
# Run ESLint on staged JavaScript files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$')
if [ -n "$STAGED_FILES" ]; then
echo "Running ESLint on staged files..."
npx eslint $STAGED_FILES
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix errors before committing."
exit 1
fi
fi
exit 0
Make it executable:
chmod +x .git/hooks/pre-commit
Formatting check
#!/bin/sh
# .git/hooks/pre-commit
# Check if Prettier would change any staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|css|json|md)$')
if [ -n "$STAGED_FILES" ]; then
npx prettier --check $STAGED_FILES
if [ $? -ne 0 ]; then
echo ""
echo "Files are not formatted. Run: npx prettier --write ."
exit 1
fi
fi
Preventing secrets from being committed
#!/bin/sh
# .git/hooks/pre-commit
# Check for common secret patterns in staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
for FILE in $STAGED_FILES; do
if grep -qE '(AKIA[A-Z0-9]{16}|sk-[a-zA-Z0-9]{48}|-----BEGIN (RSA |EC )?PRIVATE KEY-----)' "$FILE" 2>/dev/null; then
echo "BLOCKED: Possible secret detected in $FILE"
echo "Review the file and remove secrets before committing."
exit 1
fi
done
Commit-Msg Hook: Enforce Message Conventions
The commit-msg hook receives the path to the file containing the commit message. Use it to enforce a format like Conventional Commits.
#!/bin/sh
# .git/hooks/commit-msg
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Enforce Conventional Commits format
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,72}$"
if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo ""
echo "Expected: <type>(<scope>): <subject>"
echo "Examples:"
echo " feat(auth): add OAuth2 login flow"
echo " fix(search): handle empty query parameter"
echo " docs: update API reference"
echo ""
echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
exit 1
fi
This rejects messages like “fixed stuff” and requires structured messages like “fix(auth): handle expired tokens.”
Pre-Push Hook: Run Tests Before Pushing
The pre-push hook runs before data is transferred to the remote. Use it to run the full test suite and prevent pushing broken code:
#!/bin/sh
# .git/hooks/pre-push
echo "Running tests before push..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
echo "Fix failing tests and try again."
exit 1
fi
echo "All tests passed. Pushing..."
exit 0
Protecting specific branches
#!/bin/sh
# .git/hooks/pre-push
PROTECTED_BRANCHES="main master production"
CURRENT_BRANCH=$(git symbolic-ref HEAD 2>/dev/null | sed 's|refs/heads/||')
for BRANCH in $PROTECTED_BRANCHES; do
if [ "$CURRENT_BRANCH" = "$BRANCH" ]; then
echo "ERROR: Direct push to '$BRANCH' is not allowed."
echo "Create a pull request instead."
exit 1
fi
done
exit 0
Managing Hooks with Husky
The biggest problem with Git hooks is that .git/hooks/ is not tracked by Git. Every team member needs to set up hooks manually. Husky solves this by storing hooks in the repository and installing them automatically.
Setting up Husky
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky init
This creates a .husky/ directory in your project root and adds a prepare script to package.json that runs husky on npm install.
Adding hooks with Husky
# Pre-commit hook
echo "npx lint-staged" > .husky/pre-commit
# Commit-msg hook
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
# Pre-push hook
echo "npm test" > .husky/pre-push
The .husky/ directory is committed to the repo. When teammates run npm install, Husky automatically sets up the hooks.
Directory structure
project/
.husky/
pre-commit # Runs lint-staged
commit-msg # Validates commit message
pre-push # Runs tests
package.json # "prepare": "husky" in scripts
Lint-Staged: Only Lint What Changed
Running linters on the entire codebase is slow. lint-staged runs linters only on staged files:
npm install --save-dev lint-staged
Add configuration to package.json:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss}": [
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}
The pre-commit hook in .husky/pre-commit runs npx lint-staged, which:
- Identifies files in the staging area.
- Runs the matching commands.
- Re-stages any fixed files.
- Fails the commit if any command exits non-zero.
Commitlint: Validate Commit Messages
Pair Husky with commitlint for team-wide commit message standards:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'subject-max-length': [2, 'always', 72],
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]]
}
};
Now the commit-msg hook rejects non-conforming messages automatically.
Complete Team Setup
Here is the full configuration for a JavaScript/TypeScript project:
# Install all dependencies
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional eslint prettier
# Initialize Husky
npx husky init
# Set up hooks
echo "npx lint-staged" > .husky/pre-commit
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
echo "npm test" > .husky/pre-push
When a new team member joins:
git clone https://github.com/org/project.git
cd project
npm install # Husky sets up hooks automatically
Every commit is linted, every message is validated, every push runs tests. No manual setup required.
Bypassing Hooks When Needed
Sometimes you need to skip hooks, for example when rebasing or making a trivial change:
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "chore: update lockfile"
# Skip pre-push hook
git push --no-verify
Use --no-verify sparingly. If you find yourself skipping hooks often, the hooks are probably too slow or too strict.
Server-Side Hooks
Client-side hooks can be bypassed. For critical checks, use server-side hooks on your Git hosting platform:
- pre-receive: Runs on the server before accepting a push. Reject pushes that do not meet your standards.
- update: Similar to pre-receive but runs once per branch being updated.
- post-receive: Runs after the push is accepted. Trigger deployments, notifications, or CI pipelines.
GitHub, GitLab, and Bitbucket provide their own mechanisms (branch protection rules, required status checks) that effectively serve as server-side hooks without needing raw hook scripts.
Wrap-Up
Git hooks turn manual checks into automatic guardrails. Pre-commit hooks catch lint errors and formatting issues before they reach the repository. Commit-msg hooks enforce message conventions that make changelogs and git log useful. Pre-push hooks run tests before code reaches the remote. Husky and lint-staged make this setup portable across your entire team with zero manual configuration per developer. Start with a pre-commit hook for linting, add commitlint for message validation, and expand from there.
Related articles
- Git Git Hooks and pre-commit Tutorial
Automate checks before commits, pushes, and merges with native git hooks and the pre-commit framework. Keep your repo clean without slowing down.
- Git Git Hooks for CI/CD and Automation Pipelines
Build custom Git hooks that integrate with CI/CD pipelines: server-side hooks, push-based triggers, branch protection enforcement, and automated deployment gates.
- CI/CD GitHub Actions Reusable Workflows Tutorial
Stop copy-pasting CI YAML across repos. Learn how to build reusable GitHub Actions workflows with inputs, secrets, outputs, and per-environment overrides.
- DevOps Git Branching Strategies: Trunk vs Gitflow vs GitHub Flow
Compare trunk-based development, gitflow, and GitHub flow. Learn when each strategy fits, how they affect release cadence, and which commands to actually use day to day.