Skip to content
Codeloom
Git

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.

·7 min read · By Codeloom
Intermediate 13 min read

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   |
Git hook execution points in a typical workflow

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:

  1. Identifies files in the staging area.
  2. Runs the matching commands.
  3. Re-stages any fixed files.
  4. 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.