Pre-Commit Hooks

CI/CD pipelines catch issues after code is pushed. Pre-commit hooks catch issues before code is even committed. They run automatically on your local machine every time you run git commit, checking your staged changes for linting errors, formatting inconsistencies, leaked secrets, and other problems. If a hook fails, the commit is aborted, and you fix the issue immediately — before anyone else sees it.

Pre-commit hooks represent the ultimate "shift left" in quality engineering. By catching issues at the earliest possible moment, they eliminate wasted CI minutes, prevent embarrassing secrets leaks, and keep the commit history clean. This lesson covers the tools and techniques for setting up effective pre-commit hooks in any project.

How Git Hooks Work

Git has a built-in hooks system. Hooks are scripts stored in the .git/hooks/ directory that Git executes at specific points in the workflow. The most commonly used hooks for quality engineering are:

  • pre-commit: Runs before a commit is created. This is where you run linters, formatters, and scanners against staged files. If the hook exits with a non-zero code, the commit is aborted.
  • commit-msg: Runs after you write the commit message but before the commit is finalized. This is where you validate commit message format (e.g., Conventional Commits).
  • pre-push: Runs before git push sends commits to the remote. This is where you can run tests or other slower checks that you do not want on every commit but want before code leaves your machine.

The challenge with raw Git hooks is that they live in .git/hooks/, which is not tracked by version control. This means each developer has to set up hooks manually, and there is no guarantee that everyone on the team is running the same checks. Tools like Husky and the pre-commit framework solve this by managing hooks as part of the project configuration.

Husky: Git Hooks for JavaScript/TypeScript Projects

Husky is the most popular Git hooks manager for JavaScript and TypeScript projects. It installs hook scripts that are tracked in your repository, so every developer who clones the project gets the same hooks automatically.

Here is how to set up Husky step by step:

# 1. Install Husky as a dev dependency
npm install --save-dev husky

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

# 3. The init command creates a sample pre-commit hook
# Edit .husky/pre-commit to run your checks
echo "npm run lint" > .husky/pre-commit

After this setup, every time a developer runs git commit, the linter runs against the entire codebase. But running the linter against the entire codebase on every commit can be slow, especially on large projects. That is where lint-staged comes in.

lint-staged: Run Checks Only on Staged Files

lint-staged is a companion tool to Husky that runs linters and formatters only on the files that are staged for commit. This is dramatically faster than running checks against the entire codebase and ensures that the checks are relevant to the changes being committed.

# Install lint-staged
npm install --save-dev lint-staged

Configure lint-staged in your package.json:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "stylelint --fix",
      "prettier --write"
    ],
    "*.{json,md,yaml,yml}": [
      "prettier --write"
    ],
    "*.html": [
      "html-validate"
    ]
  }
}

Then update the Husky pre-commit hook to use lint-staged:

# .husky/pre-commit
npx lint-staged

Now, when a developer commits changes to a JavaScript file, ESLint and Prettier run only on that file. If they commit changes to a CSS file, Stylelint and Prettier run on that file. The --fix flag means the tools automatically fix issues where possible and stage the corrected version — the developer does not even need to know about the issue in many cases.

Key insight: The combination of Husky + lint-staged is the single most impactful pre-commit setup for JavaScript/TypeScript projects. It typically adds less than 2 seconds to the commit process while catching the majority of formatting and linting issues before they reach the repository.

The pre-commit Framework

For projects that are not JavaScript-based, or for polyglot repositories that use multiple languages, the pre-commit framework is an excellent alternative. It is a Python-based tool that manages Git hooks across any programming language.

Install the framework and create a configuration file:

# Install pre-commit
pip install pre-commit

# Create .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: check-added-large-files
        args: ['--maxkb=500']

  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.56.0
    hooks:
      - id: eslint
        types: [javascript]

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier

# Install the hooks
pre-commit install

The pre-commit framework downloads and manages the hook tools automatically, runs them in isolated environments, and supports a vast library of community-maintained hooks for virtually every language and tool.

Secret Scanning in Pre-Commit Hooks

One of the most valuable pre-commit checks is secret scanning. A leaked API key or database password committed to a public (or even private) repository is a serious security incident. Pre-commit secret scanning catches these before they enter the repository history.

Gitleaks is the most popular tool for this purpose. It scans staged changes for patterns that match API keys, tokens, passwords, and other secrets:

# With the pre-commit framework:
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks

# With Husky, add a step to .husky/pre-commit:
npx gitleaks detect --staged --verbose

Gitleaks uses a comprehensive set of regular expressions to detect common secret patterns: AWS access keys, GitHub tokens, Stripe keys, database connection strings, private keys, and hundreds of others. You can customize the rules or add exceptions for false positives using a .gitleaks.toml configuration file:

# .gitleaks.toml
[allowlist]
  description = "Allow specific patterns"
  paths = [
    '''test/fixtures/.*''',
    '''docs/examples/.*'''
  ]
Critical point: Secret scanning in pre-commit hooks is your last line of defense. Even if a developer accidentally pastes a real API key into a configuration file, the hook catches it before the commit is created. This is far better than discovering the leak after it has been pushed, where removing it from Git history is difficult and the key may have already been compromised.

Commit Message Validation

Consistent commit messages make the Git history readable, enable automated changelog generation, and support semantic versioning. The Conventional Commits specification is the most widely adopted format:

type(scope): description

feat(auth): add OAuth2 login support
fix(api): handle null response from payment gateway
docs(readme): update installation instructions
chore(deps): upgrade lodash to 4.17.21
test(cart): add unit tests for discount calculation

The valid types are: feat, fix, docs, style, refactor, perf, test, chore, ci, and build. The scope is optional. Breaking changes are indicated with ! after the type: feat!: remove legacy API.

You can enforce Conventional Commits with a commit-msg hook. The commitlint tool is the standard solution:

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

# Create commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional']
};

# Add commit-msg hook via Husky
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg

Now, if a developer writes a commit message like "fixed stuff", the hook rejects it and prompts for a properly formatted message. This may feel strict at first, but the resulting Git history is dramatically more useful for understanding what changed and why.

Common Hook Combinations

Here is a recommended set of pre-commit hooks for different project types:

  • JavaScript/TypeScript project: Husky + lint-staged (ESLint, Prettier) + Gitleaks + commitlint
  • Python project: pre-commit framework (black, ruff, mypy) + Gitleaks + commitlint
  • Multi-language project: pre-commit framework (language-specific linters, Prettier, Gitleaks, trailing whitespace, check-yaml, check-json)
  • Any project (minimal): Gitleaks for secret scanning. This is the single highest-value hook you can add.

Why Pre-Commit Hooks Reduce CI Failures

Without pre-commit hooks, the feedback loop for quality issues looks like this:

  1. Developer writes code
  2. Developer commits and pushes
  3. CI pipeline starts (1-2 minutes to initialize)
  4. Linting step fails after 30 seconds
  5. Developer sees the failure 3-5 minutes later
  6. Developer fixes the issue, commits, pushes again
  7. CI runs again (another 3-5 minutes)

Total time wasted: 6-10 minutes, plus the context-switching cost. With pre-commit hooks, the same scenario becomes:

  1. Developer writes code
  2. Developer runs git commit
  3. Hook runs lint-staged (1-2 seconds)
  4. Issue is auto-fixed or reported immediately
  5. Developer commits the clean code
  6. CI pipeline passes on the first try

Multiply this by dozens of commits per day across a team, and pre-commit hooks save hours of wasted CI time and developer frustration.

Handling Hook Bypass

Git allows developers to skip hooks with git commit --no-verify (or -n). This is sometimes necessary for legitimate reasons (e.g., committing a work-in-progress that is intentionally incomplete), but it can also be abused to skip quality checks.

The key insight is that pre-commit hooks are a convenience, not a security boundary. They catch issues early, but the CI/CD pipeline and its quality gates are the final authority. If a developer bypasses hooks and pushes code with linting errors, the CI pipeline should still catch and block the merge.

Pre-commit hooks and CI quality gates are complementary layers of defense, not alternatives to each other.

Best practice: Use pre-commit hooks for fast, cheap checks (linting, formatting, secret scanning) and CI pipelines for comprehensive checks (tests, accessibility scans, security audits). The hooks keep the feedback loop tight; the pipeline ensures nothing slips through.

Resources