Ad – 728×90
⚙️ Advanced Git

Git Hooks – Automate Checks with .git/hooks

Git hooks are scripts that run automatically when specific Git events occur — before a commit is made, after a push, before merging. They're your first line of defense: catch lint errors before they pollute the repo, enforce commit message format consistently, run your test suite before any push reaches the remote. This lesson covers the most important hooks, how to write them, and how to share them with your team using Husky.

⏱️ 20 min read 🎯 Advanced 📅 Updated 2026

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.

Bash
# 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
⚠️
Hooks are not tracked by Git

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 nameWhen it runsCommon use
pre-commitBefore commit message is enteredLint, format, test staged files
prepare-commit-msgBefore commit message editor opensAuto-populate commit message
commit-msgAfter commit message is enteredValidate message format
post-commitAfter commit is createdNotifications, logging (informational only)
pre-pushBefore push to remoteRun full test suite
pre-rebaseBefore a rebase startsSafety 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 nameWhen it runsCommon use
pre-receiveBefore push is acceptedEnforce branch policies, reject large files
updateOnce per pushed branchPer-branch access control
post-receiveAfter push is fully acceptedTrigger CI/CD, send notifications
Ad – 336×280

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.

Bash (.git/hooks/pre-commit)
#!/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
Bash
# 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:

Bash (.git/hooks/commit-msg)
#!/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:

Bash (.git/hooks/pre-push)
#!/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:

Bash
# 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.

Bash
# 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"
JSON (package.json)
{
  "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

Can I bypass a Git hook? +

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.

What languages can I write Git hooks in? +

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.

Why isn't my hook running? +

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/.

How does Husky work with npm workspaces or monorepos? +

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.