Version Control

Git Hooks Automation: Pre-Commit to Post-Deploy

A practical guide to Git hooks automation covering pre-commit, commit-msg, pre-push hooks, Husky setup, lint-staged, commitlint, secret detection, and team-wide hook sharing.

Git Hooks Automation: Pre-Commit to Post-Deploy

Git hooks are scripts that Git executes automatically at specific points in your workflow -- before a commit, after a merge, before a push. They are the cheapest quality gate you can add to a project, catching bad code, malformed commit messages, and leaked secrets before they ever reach a remote repository. If you are not using them, you are relying entirely on CI pipelines that run minutes later and cost real money.

This article walks through every hook that matters, shows how to share them across a team, and builds a production-ready setup using Husky, lint-staged, and commitlint in a Node.js project.

Prerequisites

  • Git 2.9 or later (for core.hooksPath support)
  • Node.js v18+ installed
  • A terminal you are comfortable with (bash, zsh, or Git Bash on Windows)
  • Basic Git knowledge: commits, branches, staging area
  • Familiarity with npm and package.json

How Git Hooks Work

Every Git repository has a .git/hooks directory. When you initialize a repository with git init, Git populates this directory with sample scripts:

ls .git/hooks/
applypatch-msg.sample      pre-commit.sample
commit-msg.sample           pre-merge-commit.sample
fsmonitor-watchman.sample   pre-push.sample
post-update.sample          pre-rebase.sample
pre-applypatch.sample       pre-receive.sample
prepare-commit-msg.sample   update.sample

Each .sample file is a template. To activate a hook, you remove the .sample extension and make the script executable. The script can be written in any language -- bash, Python, Node.js, Perl -- as long as it has a valid shebang line and the executable bit set.

# Activate the pre-commit hook
cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

The critical rule: if a hook script exits with a non-zero status, the Git operation is aborted. A pre-commit hook that exits with 1 stops the commit. A pre-push hook that exits with 1 stops the push. This is the enforcement mechanism.

Here is the simplest possible pre-commit hook:

#!/bin/sh
# .git/hooks/pre-commit
echo "Running pre-commit checks..."

# Run linter
npm run lint
if [ $? -ne 0 ]; then
    echo "Lint failed. Fix errors before committing."
    exit 1
fi

exit 0

Client-Side Hooks

Client-side hooks run on the developer's machine. They are the ones you will use most.

pre-commit

Fires before Git even prompts you for a commit message. This is where you run linters, formatters, type checkers, and secret scanners. If it exits non-zero, the commit is aborted and nothing is staged.

#!/bin/sh
# Runs before the commit message editor opens
echo "Pre-commit: checking staged files..."
npm run lint:staged

This is the most commonly automated hook. I run ESLint, Prettier checks, and secret detection here on every project.

prepare-commit-msg

Fires after Git creates the default commit message but before the editor opens. Use this to prepend branch names, ticket numbers, or templates to your commit messages automatically.

#!/bin/sh
# .git/hooks/prepare-commit-msg
# Prepend the branch name (e.g., feature/JIRA-123) to the commit message

BRANCH_NAME=$(git symbolic-ref --short HEAD)
TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+')

if [ -n "$TICKET" ]; then
    sed -i.bak -e "1s/^/[$TICKET] /" "$1"
fi

Now when you commit on branch feature/JIRA-456, your commit message automatically starts with [JIRA-456].

commit-msg

Fires after you write your commit message, before the commit is finalized. This is where you validate commit message format -- enforcing conventional commits, minimum length, ticket number requirements.

#!/bin/sh
# .git/hooks/commit-msg
# Enforce conventional commit format

COMMIT_MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"

if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
    echo "ERROR: Commit message does not follow Conventional Commits format."
    echo "Expected: <type>(<scope>): <description>"
    echo "Examples:"
    echo "  feat(auth): add OAuth2 login support"
    echo "  fix: resolve null pointer in user lookup"
    echo "  docs: update API reference for v3 endpoints"
    echo ""
    echo "Your message: $COMMIT_MSG"
    exit 1
fi

post-commit

Fires after the commit completes. This hook cannot stop the commit because it has already happened. Use it for notifications, logging, or triggering builds.

#!/bin/sh
# .git/hooks/post-commit
echo "Commit successful: $(git log -1 --pretty=format:'%h %s')"
# Could send a Slack notification, update a dashboard, etc.

pre-push

Fires before objects are transferred to the remote. This is your last chance to stop bad code from reaching the shared repository. Run your test suite here, validate branch names, or check that you are not pushing to a protected branch.

#!/bin/sh
# .git/hooks/pre-push
# Run tests before pushing

echo "Running test suite before push..."
npm test

if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
fi

# Prevent direct pushes to main
BRANCH=$(git symbolic-ref --short HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
    echo "ERROR: Direct push to $BRANCH is not allowed."
    echo "Create a feature branch and open a pull request."
    exit 1
fi

exit 0

Server-Side Hooks

Server-side hooks run on the repository that receives a push. If you host your own Git server (Gitea, GitLab self-hosted), these are extremely powerful. If you use GitHub or GitLab SaaS, you accomplish the same thing through branch protection rules and CI pipelines.

pre-receive

Fires once when a push arrives. It receives all the refs being pushed on stdin. Reject the push by exiting non-zero.

#!/bin/sh
# Server-side: reject pushes with commits larger than 10MB
while read oldrev newrev refname; do
    # Check each new commit for oversized files
    if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
        COMMITS=$(git rev-list "$newrev")
    else
        COMMITS=$(git rev-list "$oldrev..$newrev")
    fi

    for commit in $COMMITS; do
        git diff-tree -r --diff-filter=d "$commit" | while read mode_old mode_new hash_old hash_new status filename; do
            size=$(git cat-file -s "$hash_new" 2>/dev/null || echo 0)
            if [ "$size" -gt 10485760 ]; then
                echo "ERROR: File $filename in commit $commit exceeds 10MB limit ($size bytes)"
                exit 1
            fi
        done
        if [ $? -ne 0 ]; then exit 1; fi
    done
done

update

Similar to pre-receive but runs once per branch being updated. Useful for per-branch policies.

post-receive

Fires after the entire push completes. This is where you trigger deployments, send notifications, or update external systems. Since the push has already been accepted, this hook cannot reject anything.

#!/bin/sh
# Server-side: deploy on push to main
while read oldrev newrev refname; do
    if [ "$refname" = "refs/heads/main" ]; then
        echo "Deploying to production..."
        GIT_WORK_TREE=/var/www/app git checkout -f main
        cd /var/www/app && npm install --production && pm2 restart app
        echo "Deployment complete."
    fi
done

Sharing Hooks with Your Team

The .git/hooks directory is local to each clone. It is not tracked by Git, so you cannot just commit hooks and have everyone get them automatically. This is the single biggest problem with raw Git hooks. There are two solutions.

core.hooksPath

Git 2.9 introduced the core.hooksPath configuration option. You create a directory in your repository (conventionally .githooks/), put your hook scripts there, commit them, and tell Git to use that directory instead of .git/hooks/.

# Create the hooks directory
mkdir .githooks

# Move your hooks there
cp .git/hooks/pre-commit .githooks/pre-commit
cp .git/hooks/commit-msg .githooks/commit-msg
chmod +x .githooks/*

# Configure Git to use this directory
git config core.hooksPath .githooks

# Commit the hooks
git add .githooks/
git commit -m "chore: add shared git hooks"

The catch: every developer must run git config core.hooksPath .githooks after cloning. You can automate this with a setup script or an npm prepare script:

{
  "scripts": {
    "prepare": "git config core.hooksPath .githooks"
  }
}

Now npm install automatically configures the hooks path.

The .githooks Directory Convention

Some teams create a .githooks/ directory and add a Makefile or shell script that symlinks hooks into .git/hooks/:

#!/bin/sh
# setup-hooks.sh
for hook in .githooks/*; do
    ln -sf "../../$hook" ".git/hooks/$(basename $hook)"
done
echo "Git hooks installed."

This works but is brittle. Husky is a better solution for Node.js projects.

Using Husky for Node.js Projects

Husky is the standard tool for managing Git hooks in Node.js projects. It installs hooks automatically when someone runs npm install, and it stores hook scripts in your repository so they are version-controlled and shared.

Setup

# Install Husky
npm install --save-dev husky

# Initialize Husky (creates .husky/ directory)
npx husky init

This does three things:

  1. Creates a .husky/ directory in your project root
  2. Sets core.hooksPath to .husky/_
  3. Adds a prepare script to package.json that runs husky on install

Your package.json now has:

{
  "scripts": {
    "prepare": "husky"
  }
}

Creating Hooks

Husky hooks are plain shell scripts in the .husky/ directory:

# Create a pre-commit hook
echo "npm run lint" > .husky/pre-commit

# Create a commit-msg hook
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

# Create a pre-push hook
echo "npm test" > .husky/pre-push

Each file is a shell script. No shebang needed -- Husky handles that. The file name must match the Git hook name exactly.

Here is what a real .husky/pre-commit looks like:

npx lint-staged

That is it. One line. The complexity lives in your lint-staged configuration.

lint-staged: Running Linters on Staged Files Only

The biggest performance mistake with pre-commit hooks is running linters on your entire codebase. If you have 500 JavaScript files and you changed one, there is no reason to lint all 500. lint-staged solves this by running commands only on files that are currently staged (git add-ed).

Installation

npm install --save-dev lint-staged

Configuration

Add the configuration to your package.json:

{
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md}": [
      "prettier --write"
    ],
    "*.css": [
      "prettier --write"
    ]
  }
}

Or use a separate .lintstagedrc.json file:

{
  "*.js": ["eslint --fix", "prettier --write"],
  "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
  "*.{json,yml,yaml,md}": ["prettier --write"],
  "*.css": ["stylelint --fix", "prettier --write"]
}

When you commit, lint-staged does the following:

  1. Identifies files that are staged
  2. Matches them against the glob patterns in your config
  3. Runs the specified commands on matching files
  4. Re-stages the files if they were modified (by --fix or --write)
  5. If any command exits non-zero, the commit is aborted

Here is what the output looks like when it works:

$ git commit -m "feat: add user validation"
✔ Preparing lint-staged...
✔ Running tasks for staged files...
  ✔ *.js — 2 files
    ✔ eslint --fix — passed
    ✔ prettier --write — passed
  ✔ *.json — 1 file
    ✔ prettier --write — passed
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main a1b2c3d] feat: add user validation
 3 files changed, 45 insertions(+), 12 deletions(-)

And when it fails:

$ git commit -m "feat: add broken code"
✔ Preparing lint-staged...
✘ Running tasks for staged files...
  ✘ *.js — 1 file
    ✘ eslint --fix — failed

/home/user/project/src/handlers/user.js
  12:5   error  'result' is assigned a value but never used  no-unused-vars
  18:22  error  Unexpected use of undefined                  no-undefined
  24:1   error  Missing return statement                     consistent-return

✘ 3 problems (3 errors, 0 warnings)

> lint-staged failed. Commit aborted.

Commit Message Validation with commitlint

Conventional Commits is a specification for structuring commit messages. The format is:

<type>(<scope>): <description>

[optional body]

[optional footer(s)]

Types include feat, fix, docs, style, refactor, perf, test, build, ci, chore, and revert. This format enables automatic changelog generation, semantic versioning, and makes git log actually readable.

Setting Up commitlint

# Install commitlint and the conventional config
npm install --save-dev @commitlint/cli @commitlint/config-conventional

Create a commitlint.config.js file:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert'
    ]],
    'subject-max-length': [2, 'always', 100],
    'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
    'body-max-line-length': [1, 'always', 100]
  }
};

Wire it up with a Husky commit-msg hook:

echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

Now bad commit messages are rejected immediately:

$ git commit -m "updated stuff"
⧗   input: updated stuff
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   Found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

husky - commit-msg script failed (code 1)

And a properly formatted message goes through:

$ git commit -m "feat(auth): add OAuth2 login support"
✔   Linting commit message...
[feature/oauth a1b2c3d] feat(auth): add OAuth2 login support
 4 files changed, 180 insertions(+)

Pre-Commit Checks Beyond Linting

Linting is table stakes. Here are the other checks I run in pre-commit hooks.

Secret Detection

This is non-negotiable. Accidentally committing an API key, database password, or private key to a public repository is a career-defining mistake. Detect them before they leave your machine.

#!/bin/sh
# .githooks/check-secrets.sh
# Scan staged files for potential secrets

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

# Patterns that should never appear in committed code
PATTERNS=(
    'AKIA[0-9A-Z]{16}'                    # AWS Access Key
    'sk-[a-zA-Z0-9]{48}'                  # OpenAI API Key
    'ghp_[a-zA-Z0-9]{36}'                 # GitHub Personal Access Token
    'xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+'     # Slack Bot Token
    'mongodb\+srv://[^"'\'']*@'            # MongoDB connection string
    'postgres://[^"'\'']*@'                # PostgreSQL connection string
    'password\s*[:=]\s*["\x27][^"\x27]+'   # Hardcoded passwords
    'PRIVATE KEY-----'                     # Private keys
)

FOUND=0
for file in $STAGED_FILES; do
    for pattern in "${PATTERNS[@]}"; do
        if git show ":$file" 2>/dev/null | grep -qE "$pattern"; then
            echo "ERROR: Potential secret found in $file"
            echo "Pattern: $pattern"
            git show ":$file" | grep -nE "$pattern" | head -3
            echo ""
            FOUND=1
        fi
    done
done

if [ $FOUND -eq 1 ]; then
    echo "Secrets detected in staged files. Commit aborted."
    echo "If these are false positives, use 'git commit --no-verify'"
    exit 1
fi

exit 0

File Size Limits

Prevent someone from committing a 200MB video or a node_modules zip:

#!/bin/sh
# Reject files larger than 5MB
MAX_SIZE=5242880  # 5MB in bytes

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

for file in $STAGED_FILES; do
    size=$(git cat-file -s ":$file" 2>/dev/null || echo 0)
    if [ "$size" -gt "$MAX_SIZE" ]; then
        echo "ERROR: $file is $(($size / 1048576))MB, exceeds 5MB limit."
        echo "Consider using Git LFS for large files."
        exit 1
    fi
done

Formatting Checks

Instead of auto-fixing with Prettier, some teams prefer to just check and fail:

npx prettier --check "src/**/*.js"

This catches formatting issues without modifying files during the commit process. I prefer --write with lint-staged (auto-fix and re-stage), but some teams want the developer to fix formatting explicitly.

Pre-Push Checks

Running Tests

The pre-push hook is the right place for tests because tests take longer than linting:

#!/bin/sh
# .husky/pre-push
echo "Running tests before push..."
npm test 2>&1

if [ $? -ne 0 ]; then
    echo ""
    echo "Tests failed. Push aborted."
    echo "Fix failing tests or use 'git push --no-verify' to skip."
    exit 1
fi

Branch Name Validation

Enforce a naming convention so branches are consistent:

#!/bin/sh
# Validate branch name format
BRANCH=$(git symbolic-ref --short HEAD)
PATTERN="^(feature|fix|hotfix|chore|docs|refactor|test)/[a-z0-9._-]+$"

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "develop" ]; then
    exit 0
fi

if ! echo "$BRANCH" | grep -qE "$PATTERN"; then
    echo "ERROR: Branch name '$BRANCH' does not match required pattern."
    echo "Expected format: <type>/<description>"
    echo "Types: feature, fix, hotfix, chore, docs, refactor, test"
    echo "Example: feature/add-user-auth"
    exit 1
fi

Post-Merge Hooks

The post-merge hook fires after a successful git merge or git pull. The most useful automation here is auto-installing dependencies when package.json changes:

#!/bin/sh
# .husky/post-merge
# Auto-install dependencies if package.json changed

CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)

if echo "$CHANGED_FILES" | grep -q "package.json"; then
    echo "package.json changed. Running npm install..."
    npm install
    echo "Dependencies updated."
fi

if echo "$CHANGED_FILES" | grep -q "package-lock.json"; then
    echo "package-lock.json changed. Running npm ci..."
    npm ci
    echo "Dependencies synchronized."
fi

This eliminates the "it works on my machine" issue where someone pulls changes that add a new dependency but forgets to run npm install.

Building a File-Type-Aware Pre-Commit Hook

Sometimes you want to run different checks based on what types of files are being committed. Here is a hook that dispatches to different tools based on file extensions:

#!/bin/sh
# .githooks/pre-commit-dispatch.sh
# Run different checks based on staged file types

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
EXIT_CODE=0

# Collect file types
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|jsx)$' || true)
TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
CSS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(css|scss)$' || true)
PY_FILES=$(echo "$STAGED_FILES" | grep -E '\.py$' || true)
DOCKER_FILES=$(echo "$STAGED_FILES" | grep -E 'Dockerfile' || true)
YAML_FILES=$(echo "$STAGED_FILES" | grep -E '\.(yml|yaml)$' || true)
SQL_FILES=$(echo "$STAGED_FILES" | grep -E '\.sql$' || true)

# JavaScript/JSX
if [ -n "$JS_FILES" ]; then
    echo "Checking JavaScript files..."
    echo "$JS_FILES" | xargs npx eslint --fix
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
    echo "$JS_FILES" | xargs npx prettier --write
    echo "$JS_FILES" | xargs git add
fi

# TypeScript/TSX
if [ -n "$TS_FILES" ]; then
    echo "Checking TypeScript files..."
    echo "$TS_FILES" | xargs npx eslint --fix
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
    npx tsc --noEmit
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi

# CSS/SCSS
if [ -n "$CSS_FILES" ]; then
    echo "Checking CSS files..."
    echo "$CSS_FILES" | xargs npx stylelint --fix
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi

# Python
if [ -n "$PY_FILES" ]; then
    echo "Checking Python files..."
    echo "$PY_FILES" | xargs python -m flake8
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
    echo "$PY_FILES" | xargs python -m black --check
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi

# Dockerfiles
if [ -n "$DOCKER_FILES" ]; then
    echo "Linting Dockerfiles..."
    echo "$DOCKER_FILES" | xargs npx dockerfilelint
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi

# YAML
if [ -n "$YAML_FILES" ]; then
    echo "Validating YAML files..."
    echo "$YAML_FILES" | xargs npx yaml-lint
    if [ $? -ne 0 ]; then EXIT_CODE=1; fi
fi

exit $EXIT_CODE

This approach means you only run relevant tools. Committing a Markdown file does not trigger ESLint. Committing a Dockerfile does not run stylelint.

Bypassing Hooks

Every hook can be bypassed with the --no-verify flag:

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "wip: debugging production issue"

# Skip pre-push hook
git push --no-verify

When is this acceptable? Honestly, almost never. But here are the legitimate cases:

  • Emergency hotfix in production: The build is down, you have a one-line fix, and the linter is complaining about an unrelated import ordering issue. Ship the fix. Clean it up in the next commit.
  • WIP commits on a feature branch: You are saving your work at the end of the day on a branch that only you use. Nobody else will see these commits, and you will squash them before merging.
  • False positives in secret detection: Your test fixture contains a string that looks like an API key pattern but is not one.

What is not acceptable: bypassing hooks because "the linter is annoying" or "the tests take too long." If the hooks are too slow, fix the hooks. Do not work around them.

Some teams track --no-verify usage by adding a post-commit hook that logs when hooks were skipped. That is a reasonable approach for audit purposes.

Hook Performance Optimization

Slow hooks are hooks that get bypassed. Here are the techniques I use to keep hooks fast.

Only Check Changed Files

This is what lint-staged does automatically, but if you are writing custom hooks:

# Bad: lint everything
npx eslint src/

# Good: lint only staged files
STAGED=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$STAGED" ]; then
    echo "$STAGED" | xargs npx eslint
fi

Parallel Execution

Run independent checks in parallel:

#!/bin/sh
# Run lint and type-check in parallel

npm run lint &
LINT_PID=$!

npm run typecheck &
TYPE_PID=$!

wait $LINT_PID
LINT_EXIT=$?

wait $TYPE_PID
TYPE_EXIT=$?

if [ $LINT_EXIT -ne 0 ] || [ $TYPE_EXIT -ne 0 ]; then
    echo "Pre-commit checks failed."
    exit 1
fi

Cache ESLint Results

ESLint supports caching, which dramatically speeds up repeated runs:

{
  "lint-staged": {
    "*.js": ["eslint --fix --cache --cache-location .eslintcache"]
  }
}

Add .eslintcache to your .gitignore.

Skip Hooks in CI

Your CI pipeline runs its own checks. There is no reason to run client-side hooks inside CI:

# In your CI configuration
export HUSKY=0
# or
git config --global core.hooksPath /dev/null

Husky respects the HUSKY=0 environment variable and skips hook installation.

Complete Working Example

Here is a full Node.js project setup with Husky, lint-staged, commitlint, secret detection, and branch naming enforcement. This is what I use on production projects.

package.json

{
  "name": "my-api-service",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "test": "mocha --recursive test/",
    "lint": "eslint src/ --cache",
    "lint:fix": "eslint src/ --fix --cache",
    "format": "prettier --write \"src/**/*.js\"",
    "format:check": "prettier --check \"src/**/*.js\"",
    "typecheck": "tsc --noEmit",
    "prepare": "husky"
  },
  "devDependencies": {
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0",
    "eslint": "^8.56.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.2.0",
    "mocha": "^10.2.0",
    "prettier": "^3.2.0"
  },
  "lint-staged": {
    "*.js": [
      "eslint --fix --cache --cache-location .eslintcache",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ]
  }
}

commitlint.config.js

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'perf', 'test', 'build', 'ci', 'chore', 'revert'
    ]],
    'subject-max-length': [2, 'always', 100],
    'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
    'body-max-line-length': [1, 'always', 120],
    'header-max-length': [2, 'always', 120]
  }
};

.husky/pre-commit

# Run lint-staged for linting and formatting
npx lint-staged

# Run secret detection
sh .githooks/check-secrets.sh

.husky/commit-msg

npx --no -- commitlint --edit $1

.husky/pre-push

# Run tests
echo "Running test suite..."
npm test

if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
fi

# Validate branch name
BRANCH=$(git symbolic-ref --short HEAD)
VALID_PATTERN="^(feature|fix|hotfix|chore|docs|refactor|test)/[a-z0-9._-]+$"

if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "develop" ]; then
    exit 0
fi

if ! echo "$BRANCH" | grep -qE "$VALID_PATTERN"; then
    echo "ERROR: Branch '$BRANCH' does not follow naming convention."
    echo "Use: <type>/<description> (e.g., feature/add-user-auth)"
    exit 1
fi

.husky/post-merge

# Auto-install when package.json changes
CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)

if echo "$CHANGED" | grep -qE "package(-lock)?\.json"; then
    echo "Dependencies changed. Running npm install..."
    npm install
fi

.githooks/check-secrets.sh

#!/bin/sh
# Secret detection for staged files

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
FOUND_SECRET=0

for file in $STAGED_FILES; do
    # Skip binary files and common non-secret files
    case "$file" in
        *.png|*.jpg|*.gif|*.ico|*.woff|*.ttf|*.lock) continue ;;
    esac

    CONTENT=$(git show ":$file" 2>/dev/null)

    # AWS Access Key
    if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
        echo "ALERT: Possible AWS Access Key in $file"
        FOUND_SECRET=1
    fi

    # Generic API Key patterns
    if echo "$CONTENT" | grep -qE '(api[_-]?key|apikey)\s*[:=]\s*["\x27][a-zA-Z0-9]{20,}'; then
        echo "ALERT: Possible API key in $file"
        FOUND_SECRET=1
    fi

    # Private keys
    if echo "$CONTENT" | grep -q 'PRIVATE KEY-----'; then
        echo "ALERT: Private key detected in $file"
        FOUND_SECRET=1
    fi

    # Connection strings with credentials
    if echo "$CONTENT" | grep -qE '(mongodb|postgres|mysql|redis)(\+srv)?://[^/]+:[^@]+@'; then
        echo "ALERT: Database connection string with credentials in $file"
        FOUND_SECRET=1
    fi

    # OpenAI / Anthropic keys
    if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{40,}'; then
        echo "ALERT: Possible API secret key in $file"
        FOUND_SECRET=1
    fi

    # .env file being committed
    if echo "$file" | grep -qE '^\.env(\.|$)'; then
        echo "ALERT: .env file being committed: $file"
        FOUND_SECRET=1
    fi
done

if [ $FOUND_SECRET -eq 1 ]; then
    echo ""
    echo "Secrets detected. Commit aborted."
    echo "Move secrets to environment variables or .env (which should be in .gitignore)."
    echo "Use 'git commit --no-verify' only if these are false positives."
    exit 1
fi

exit 0

Custom Hook: Node.js Secret Scanner

For more sophisticated detection, you can write hooks in Node.js. Here is a scanner that checks for high-entropy strings that might be leaked tokens:

#!/usr/bin/env node
// .githooks/scan-entropy.js
// Detect high-entropy strings that might be secrets

var childProcess = require('child_process');
var path = require('path');

var ENTROPY_THRESHOLD = 4.5;
var MIN_LENGTH = 20;

function calculateEntropy(str) {
    var freq = {};
    var len = str.length;

    for (var i = 0; i < len; i++) {
        var c = str[i];
        freq[c] = (freq[c] || 0) + 1;
    }

    var entropy = 0;
    var keys = Object.keys(freq);
    for (var j = 0; j < keys.length; j++) {
        var p = freq[keys[j]] / len;
        entropy -= p * Math.log2(p);
    }

    return entropy;
}

function getStagedFiles() {
    var result = childProcess.execSync(
        'git diff --cached --name-only --diff-filter=ACM',
        { encoding: 'utf-8' }
    );
    return result.trim().split('\n').filter(Boolean);
}

function getFileContent(file) {
    try {
        return childProcess.execSync(
            'git show ":' + file + '"',
            { encoding: 'utf-8' }
        );
    } catch (e) {
        return '';
    }
}

var files = getStagedFiles();
var found = false;

files.forEach(function(file) {
    // Skip non-text files
    if (/\.(png|jpg|gif|ico|woff|ttf|lock|map)$/.test(file)) return;

    var content = getFileContent(file);
    var lines = content.split('\n');

    lines.forEach(function(line, idx) {
        // Find quoted strings
        var matches = line.match(/["']([a-zA-Z0-9+/=_-]{20,})["']/g);
        if (!matches) return;

        matches.forEach(function(match) {
            var value = match.slice(1, -1);
            if (value.length < MIN_LENGTH) return;

            var entropy = calculateEntropy(value);
            if (entropy > ENTROPY_THRESHOLD) {
                console.log(
                    'WARNING: High-entropy string in ' + file +
                    ':' + (idx + 1)
                );
                console.log(
                    '  Value: ' + value.substring(0, 12) + '...' +
                    ' (entropy: ' + entropy.toFixed(2) + ')'
                );
                found = true;
            }
        });
    });
});

if (found) {
    console.log('');
    console.log('High-entropy strings detected. These might be secrets.');
    console.log('Review the warnings above. Use --no-verify if they are false positives.');
    process.exit(1);
}

process.exit(0);

Project Initialization Script

Tie everything together with a setup command:

#!/bin/sh
# setup.sh - Run after cloning the repository

echo "Installing dependencies..."
npm install

echo "Making custom hooks executable..."
chmod +x .githooks/*.sh .githooks/*.js

echo "Verifying Husky installation..."
if [ -d ".husky" ]; then
    echo "Husky hooks directory found."
else
    echo "ERROR: Husky not initialized. Run 'npx husky init'"
    exit 1
fi

echo ""
echo "Setup complete. The following hooks are active:"
echo "  pre-commit:  lint-staged + secret detection"
echo "  commit-msg:  conventional commit validation"
echo "  pre-push:    test suite + branch name validation"
echo "  post-merge:  auto npm install on dependency changes"

Common Issues and Troubleshooting

1. Hook Not Executing

$ git commit -m "feat: add new feature"
[main a1b2c3d] feat: add new feature

The commit goes through with no hook output. The hook is either not installed, not executable, or the path is wrong.

Fix:

# Check if hooks path is configured
git config core.hooksPath
# Should output: .husky/_

# Check if the hook file exists and is executable
ls -la .husky/pre-commit
# Should show: -rwxr-xr-x

# On Windows, executable bit is not relevant, but the file must exist
# Re-initialize Husky
npx husky init

2. Husky Not Running After npm install

$ npm install
added 847 packages in 12s

$ git commit -m "test"
[main b2c3d4e] test

The prepare script did not run. This happens when npm install is run with --ignore-scripts or when the prepare script is missing from package.json.

Fix:

# Verify the prepare script exists
node -e "var p = require('./package.json'); console.log(p.scripts.prepare)"
# Should output: husky

# Run prepare manually
npm run prepare

# If using npm ci in CI, set HUSKY=0 to intentionally skip
HUSKY=0 npm ci

3. lint-staged Fails with "No staged files match any configured task"

$ git commit -m "chore: update readme"
✔ Preparing lint-staged...
ℹ No staged files match any configured task.
✔ Cleaning up temporary files...

This is not actually an error -- it is informational. If you are committing a .txt file and your lint-staged config only has patterns for *.js and *.json, nothing runs. This is correct behavior.

If you expected files to match but they did not, check your glob patterns. A common mistake is writing src/**/*.js when the staged file is at the project root.

4. commit-msg Hook Fails on Merge Commits

$ git merge feature/auth
⧗   input: Merge branch 'feature/auth' into main
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

Git auto-generates merge commit messages that do not follow conventional commit format. The fix is to tell commitlint to ignore merge commits:

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  ignores: [
    function(message) {
      return /^Merge\s/.test(message);
    }
  ]
};

5. Hooks Fail in GUI Git Clients

Some GUI Git clients (SourceTree, GitKraken, VS Code's built-in Git) do not always respect core.hooksPath or fail to find npx because they do not inherit the terminal's PATH.

Fix: Add the Node.js bin directory to the hook script:

# .husky/pre-commit
export PATH="/usr/local/bin:$HOME/.nvm/versions/node/$(node -v)/bin:$PATH"
npx lint-staged

On Windows with nvm-windows, the path issue is less common, but you may need to ensure the Husky scripts use the correct shell.

6. pre-push Hook Runs Tests Twice

If your pre-push hook runs npm test and your CI also runs tests, that is intentional -- they serve different purposes. But if the hook is running twice locally, it is usually because you have hooks in both .git/hooks/ and .husky/:

# Check for duplicate hooks
ls .git/hooks/pre-push
ls .husky/pre-push

# Remove the old one
rm .git/hooks/pre-push

Best Practices

  • Keep pre-commit hooks under 10 seconds. If your pre-commit hook takes 30 seconds, developers will start using --no-verify. Use lint-staged to only check changed files. Use ESLint caching. Run expensive checks (full test suites, integration tests) in the pre-push hook instead.

  • Always commit your hook configuration. Husky's .husky/ directory, your .lintstagedrc, your commitlint.config.js -- all of these must be in version control. A hook setup that only works on your machine is worse than no hooks at all because it creates a false sense of security.

  • Use the prepare script for automatic setup. Any developer who runs npm install should have hooks installed automatically. They should never need to run a separate setup command or read a wiki page. If hooks require manual configuration, they will not be used.

  • Secret detection is not optional. Run a secret scanner in your pre-commit hook on every project, even personal ones. A leaked API key on a public GitHub repository can be detected and exploited in minutes. The cost of adding a secret scanner is trivial compared to the cost of a breach.

  • Do not run your full test suite in pre-commit. Save comprehensive tests for pre-push or CI. The pre-commit hook should be fast enough that developers do not even notice it. Linting and formatting, yes. A 45-second integration test suite, no.

  • Treat --no-verify as a code smell. It exists for genuine emergencies. If someone on your team is using it regularly, the hooks are too slow, too strict, or both. Fix the root cause instead of working around it.

  • Set up post-merge hooks for dependency management. Auto-running npm install after git pull when package.json changes eliminates an entire category of "works on my machine" bugs. This is a five-minute setup that saves hours of debugging over the life of a project.

  • Test your hooks in CI. Run npx commitlint --from=HEAD~1 and npx lint-staged --diff="HEAD~1" in your CI pipeline. This catches cases where someone bypassed hooks locally. Your CI pipeline should enforce the same rules that your hooks enforce.

  • Document the hook setup in your project README. A one-paragraph explanation of what hooks are installed and what they enforce sets expectations for new contributors. Include the --no-verify escape hatch so people know it exists for legitimate cases.

References

Powered by Contentful