Programming Intermediate 8 min

How to Create a Pre-Commit Git Hook That Lints Before Pushing

A pre-commit hook runs a script the moment you type git commit, before any commit object is created. If the script exits with a non-zero status, the commit is blocked. Done right, this catches lint errors and formatting violations before they ever reach the repository — no CI failure, no review comment saying "you forgot to run prettier".

Step-by-step

  1. 1

    Understand the raw hook approach

    Every Git repository has a .git/hooks/ directory with sample scripts. Create an executable file named pre-commit there and Git will run it before each commit. The critical limitation: .git/ is never committed, so the hook only lives on your machine and is not shared with the team.

    bash
    ls .git/hooks/
    # pre-commit.sample  commit-msg.sample  post-merge.sample ...
    
    # Create a raw hook (not the recommended long-term approach)
    cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit
  2. 2

    Write a raw bash hook that blocks on errors

    A pre-commit hook is a plain shell script. Exit 0 to allow the commit; exit anything else to abort it. This example runs ESLint on every staged .js file and blocks if any errors are found.

    bash
    #!/bin/bash
    # .git/hooks/pre-commit
    
    STAGED_JS=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.js$')
    
    if [ -n "$STAGED_JS" ]; then
      echo "Running ESLint on staged files..."
      npx eslint $STAGED_JS
      if [ $? -ne 0 ]; then
        echo "ESLint failed. Commit aborted."
        exit 1
      fi
    fi
    
    exit 0
  3. 3

    Install Husky to commit hooks with the repo

    Husky stores hooks in a .husky/ directory that is committed, so every developer gets them automatically when they clone or pull. It is the industry standard for Node.js projects.

    bash
    npm install --save-dev husky
    
    # Initialise Husky (creates .husky/ and adds prepare script to package.json)
    npx husky-init
    
    # After init, install dependencies to trigger the prepare script:
    npm install
  4. 4

    Configure the Husky pre-commit hook

    After husky-init, a file .husky/pre-commit is created with a placeholder. Replace the placeholder with your actual command. This file is committed and shared with the entire team.

    bash
    # .husky/pre-commit
    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
    
    npx lint-staged
  5. 5

    Add lint-staged to lint only changed files

    Running your linter against the entire codebase on every commit is slow and produces noise from files you didn't touch. lint-staged runs linters only on the files that are actually staged — keeping the hook fast and the feedback relevant.

    bash
    npm install --save-dev lint-staged
  6. 6

    Configure lint-staged in package.json

    Add a lint-staged key to package.json. Each key is a glob pattern; the value is an array of commands to run on matched staged files. Prettier runs first (format), then ESLint (check for errors).

    bash
    // package.json
    {
      "lint-staged": {
        "*.{js,ts,tsx,jsx}": [
          "prettier --write",
          "eslint --fix"
        ],
        "*.{css,scss}": [
          "prettier --write"
        ],
        "*.php": [
          "./vendor/bin/phpcs --standard=PSR12"
        ]
      }
    }
  7. 7

    Test the hook and bypass when necessary

    Stage a file with a lint error and run git commit — the hook should block it. If you ever need to skip the hook legitimately (e.g. committing a temporary debug file during an incident), use --no-verify. Use this sparingly; making a habit of it defeats the purpose.

    bash
    # Normal commit — hook runs automatically
    git add src/broken.js
    git commit -m "test: trigger the hook"
    # ✘ ESLint: 3 errors found. Commit aborted.
    
    # Skip the hook — only when you genuinely mean it
    git commit --no-verify -m "chore: temp debug commit"
  8. 8

    Keep the hook under three seconds

    Developers will bypass a hook that takes more than a few seconds. lint-staged already helps by limiting scope to staged files. Additional tips: run linters in parallel where possible, disable type-checking in the pre-commit hook (run tsc --noEmit in CI instead), and avoid installing packages inside the hook script.

    bash
    # Time your hook to confirm it's fast
    time git commit --allow-empty -m "timing test"
    
    # If still slow, run tsc type-checking only in CI, not in the hook:
    # In CI (.github/workflows/ci.yml):
    #   - run: npx tsc --noEmit

Tips & gotchas

  • Hooks in <code>.git/hooks/</code> are never cloned or pushed — always use Husky (or a similar tool like <code>lefthook</code>) to share hooks with your team.
  • Add <code>node_modules/.bin</code> to the PATH inside your hook script so you can call <code>eslint</code> instead of <code>./node_modules/.bin/eslint</code>.
  • For PHP projects without Node, use <a href="https://github.com/captainhookphp/captainhook" target="_blank" rel="noopener noreferrer">CaptainHook</a> — it does the same thing with a JSON config file.
  • Run <code>git stash --include-untracked</code> at the start of your hook and <code>git stash pop</code> at the end if you need to lint a clean working tree (not just staged files).

Wrapping up

The raw .git/hooks/pre-commit approach is fine for solo projects; use Husky + lint-staged the moment you have a team. The three-second rule is non-negotiable: a hook that developers bypass because it's slow is worse than no hook at all. Keep the hook focused on fast, local checks (format + lint) and push the slow checks (type-checking, full test suite) to CI where they belong.

#Git #Hooks #Linting
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.