What Are Git Hooks?
Git hooks are executable scripts stored in the .git/hooks/ directory of your repository. When Git performs certain actions (commit, push, merge, etc.), it checks for a script with a specific name and runs it. If the script exits with a non-zero code, the Git action is aborted.
# View the sample hooks in any Git repo
ls .git/hooks/
# applypatch-msg.sample commit-msg.sample pre-commit.sample
# pre-push.sample prepare-commit-msg.sample ...
# A hook is activated by removing the .sample extension
# and making it executable
chmod +x .git/hooks/pre-commit
The .git/ directory is never committed. This means hooks placed directly in .git/hooks/ are local to your machine only. To share hooks with your team, you need a strategy like Husky (covered below) or a custom setup script.
Client-Side Hooks
Client-side hooks run on your local machine and affect your own Git operations:
| Hook name | When it runs | Common use |
|---|---|---|
pre-commit | Before commit message is entered | Lint, format, test staged files |
prepare-commit-msg | Before commit message editor opens | Auto-populate commit message |
commit-msg | After commit message is entered | Validate message format |
post-commit | After commit is created | Notifications, logging (informational only) |
pre-push | Before push to remote | Run full test suite |
pre-rebase | Before a rebase starts | Safety checks |
Server-Side Hooks
Server-side hooks run on the remote repository server. They're used in self-hosted Git servers (GitLab, Bitbucket self-managed, Gitea) for repository-level enforcement:
| Hook name | When it runs | Common use |
|---|---|---|
pre-receive | Before push is accepted | Enforce branch policies, reject large files |
update | Once per pushed branch | Per-branch access control |
post-receive | After push is fully accepted | Trigger CI/CD, send notifications |
Writing a pre-commit Hook
The most commonly used hook. It runs before you type your commit message. If it exits non-zero, the commit is cancelled.
#!/bin/sh
# pre-commit: Run ESLint on staged JS files
# Get list of staged JS/TS files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E "\.(js|ts|jsx|tsx)$")
if [ -z "$STAGED_FILES" ]; then
exit 0 # No JS/TS files staged, nothing to check
fi
echo "Running ESLint on staged files..."
npx eslint $STAGED_FILES
if [ $? -ne 0 ]; then
echo ""
echo "ESLint failed. Fix the errors above before committing."
echo "To bypass (not recommended): git commit --no-verify"
exit 1
fi
echo "ESLint passed!"
exit 0
# Make the hook executable
chmod +x .git/hooks/pre-commit
# Test it
git add src/app.js
git commit -m "test"
# Running ESLint on staged files...
# (ESLint output here)
# ESLint failed. Fix the errors above before committing.
Writing a commit-msg Hook
This hook receives the path to the file containing the commit message. Use it to enforce Conventional Commits format or your team's naming conventions:
#!/bin/sh
# commit-msg: Enforce Conventional Commits format
MSG_FILE="$1"
MSG=$(cat "$MSG_FILE")
# Regex: type(scope): description
# Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?: .{1,72}"
if ! echo "$MSG" | grep -qE "$PATTERN"; then
echo ""
echo "Invalid commit message format!"
echo "Expected: type(scope): description"
echo "Example: feat(auth): add OAuth2 login support"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
exit 1
fi
exit 0
Writing a pre-push Hook
Runs before git push. Perfect for running your full test suite — you only want working code reaching the remote:
#!/bin/sh
# pre-push: Run tests before pushing
echo "Running test suite before push..."
npm test
if [ $? -ne 0 ]; then
echo ""
echo "Tests failed! Push aborted."
echo "Fix failing tests before pushing."
exit 1
fi
echo "All tests passed. Pushing..."
exit 0
Sharing Hooks with Your Team
Since .git/hooks/ is not tracked, you need a strategy to share hooks. A simple approach: keep hooks in a .githooks/ directory in your repo and configure Git to use it:
# Create a tracked hooks directory
mkdir .githooks
cp .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git add .githooks/
git commit -m "chore: add shared git hooks"
# Each developer configures their Git to use this directory
git config core.hooksPath .githooks
# Add to onboarding docs or a setup script:
# echo "git config core.hooksPath .githooks" >> setup.sh
Husky – Team-Shared Hooks for Node.js Projects
Husky is the most popular solution for Node.js/npm projects. It installs hooks automatically when developers run npm install, and hooks are defined in package.json or separate config files — fully tracked in your repo.
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory and configures package.json)
npx husky init
# Add a pre-commit hook
echo "npm run lint" > .husky/pre-commit
# Add a commit-msg hook (using commitlint)
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
# Add a pre-push hook
echo "npm test" > .husky/pre-push
# Commit the hooks — now every developer gets them on npm install
git add .husky package.json
git commit -m "chore: add husky git hooks"
{
"scripts": {
"prepare": "husky",
"lint": "eslint src --ext .js,.ts",
"test": "jest --passWithNoTests"
},
"devDependencies": {
"husky": "^9.0.0",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0"
}
}
📋 Summary
- Git hooks are executable scripts in
.git/hooks/that run automatically at Git lifecycle events. - Non-zero exit code from a hook aborts the Git action (commit, push, etc.).
- pre-commit — runs before committing; use for linting and formatting staged files.
- commit-msg — validates commit message format; use to enforce Conventional Commits.
- pre-push — runs before pushing; use for full test suite execution.
- Hooks are NOT tracked by Git — share via a
.githooks/directory +git config core.hooksPath. - Husky is the standard solution for Node.js teams: installs hooks automatically on
npm install. - Bypass a hook (emergency only):
git commit --no-verify.
FAQ
Yes — use the --no-verify flag: git commit --no-verify or git push --no-verify. This skips all client-side hooks. While this escape hatch is necessary for genuine emergencies (e.g., a broken CI environment during an outage), using it habitually defeats the purpose of having hooks. Server-side hooks (on a self-hosted Git server) cannot be bypassed by individual developers.
Any language that can be run as an executable on your system. The shebang line (#!/bin/sh, #!/usr/bin/env python3, #!/usr/bin/env node) tells the OS how to run the script. Shell scripts are most portable across developer machines, but Python, Node.js, Ruby, or any other scripting language works fine as long as it's installed on every developer's machine.
The two most common reasons: (1) Missing execute permission — run chmod +x .git/hooks/pre-commit. On Windows, Git can sometimes be configured to treat files as executable via git update-index --chmod=+x .git/hooks/pre-commit. (2) Wrong filename — the file must be named exactly pre-commit (no extension). A file named pre-commit.sh will not run. Check with ls -la .git/hooks/.
Husky works at the Git repository root level. In a monorepo, install Husky in the root package.json with the prepare script. Your hook scripts can then selectively run lint/test commands for only the affected packages — tools like lint-staged are commonly combined with Husky to run commands only on staged files, which is much faster than linting the entire monorepo on every commit.