Setting Up Pre-Commit Hooks for Code Quality
AI generated
git
HEAD
Git · Pre-Commit Hooks · Code Quality · Linting
Setting Up Pre-Commit Hooks for Code Quality
Fast checks before the commit even exists

A pre-commit hook runs before Git actually creates the commit, and it can block it entirely on a rule violation. This article covers which checks, like linting, formatting and fast unit tests, belong there, why speed matters so much, and how hand-rolled scripts compare to the pre-commit framework.

11 min read Linting · Formatting · Unit Tests pre-commit framework · pre-commit.com

1. Why the pre-commit hook is the first checkpoint

A pre-commit hook runs after files have been staged with git add, but before Git opens the actual commit editor and creates the commit object. That exact timing makes it the earliest meaningful checkpoint in the entire developer workflow: an error gets caught before it even becomes part of the local history, not only at push time or in the CI pipeline minutes later. If the hook aborts with a non-zero exit code, Git blocks the commit entirely, and the developer lands back at the terminal with a clear error message.

The advantage over later checks lies in the context switch: a developer still in the middle of a change can fix an issue immediately, without having to jump back to an old change that has long since left their head. A pre-commit hook does not replace a full CI pipeline, but complements it with a fast, local first stage that catches obvious problems before they even reach the shared history.

2. How the pre-commit hook works technically

Technically, a pre-commit hook receives no command-line arguments, and must determine which files are actually staged itself, via git diff --cached --name-only --diff-filter=ACM. It is important to check only the staged content, not the working-directory version, because with partially staged files via git add -p the two versions can differ. The reliable way to do this is git show :file.php, which reads the content directly from the staging area.

After the check, only the exit code decides the outcome, not the text output. An exit code of 0 lets the commit through, any other value aborts it. For that reason, all sub-checks should collect their results into a shared variable instead of aborting on the first failure, so the developer sees every problem in one run instead of discovering them one at a time.


#!/usr/bin/env bash
# pre-commit hook: determine staged files and read their staged content
set -euo pipefail

# Only added, copied and modified files, deletions are irrelevant to lint
staged_files=$(git diff --cached --name-only --diff-filter=ACM)

if [ -z "$staged_files" ]; then
  echo "No staged files, skipping checks."
  exit 0
fi

exit_code=0

for file in $staged_files; do
  case "$file" in
    *.php)
      # Read the staged content directly, not the working-directory version
      if ! git show ":$file" | php -l > /dev/null 2>&1; then
        echo "Syntax error in staged version of: $file"
        exit_code=1
      fi
      ;;
  esac
done

exit $exit_code

3. Linting as a baseline check in the pre-commit hook

Linting is the most obvious and usually most rewarding check in a pre-commit hook, because it works purely statically and needs no runtime environment. A linter catches unused variables, syntax errors, forbidden constructs like var_dump in production PHP code, or violations of an agreed coding standard like PSR-12. Because linting typically takes milliseconds to a few seconds per file, it can run comfortably on every single commit without noticeably slowing down the workflow.

What matters is applying the linter only to the staged files, not the entire repository. A full lint run across thousands of files on every commit would be impractical even with a fast linter, and would undermine the whole point of a fast, local checkpoint. Tools like phpcs, eslint, or ruff all support passing individual file paths as arguments for exactly this purpose.

4. Auto-fixing formatting instead of just reporting it

Formatting checks differ from linting in one decisive way: in most cases they can be fixed automatically instead of merely reported. Tools like php-cs-fixer, prettier, or black reformat the code directly and return it in corrected form, instead of just presenting the developer with a list of deviations to fix by hand. A well-configured pre-commit hook takes advantage of this, reformats the staged files in the working directory, and re-adds the corrected versions to the staging area with git add.

This automation removes tedious manual formatting work from developers entirely and simultaneously ends endless discussions about indentation or quote style in code reviews. It is important that the hook checks again after the automatic fix whether any formatting issues remain that the tool could not resolve automatically, and rejects the commit in that case.


#!/usr/bin/env bash
# pre-commit hook: auto-fix formatting, then re-stage the fixed files
set -euo pipefail

staged_php=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')

if [ -z "$staged_php" ]; then
  exit 0
fi

# Auto-fix formatting in place on the working-directory copies
php-cs-fixer fix --using-cache=no $staged_php

# Re-add the now-formatted files so the fix is part of this commit
git add $staged_php

# Fail only if something remains that the fixer could not resolve
if ! php-cs-fixer fix --dry-run --diff $staged_php > /dev/null 2>&1; then
  echo "Formatting could not be fully auto-fixed, please review manually."
  exit 1
fi

exit 0

5. Fast unit tests in the pre-commit hook

Unit tests in the pre-commit hook make sense as long as they stay fast enough not to noticeably delay the commit. For most teams, the practical limit sits at a few seconds, never several minutes. Instead of running the entire test suite, a pre-commit hook is best kept to tests that directly correspond to the staged files, for example via a naming convention like ClassNameTest or an explicit mapping in the test configuration.

Integration tests that need a database, an external service, or a full application bootstrap generally do not belong in the pre-commit hook, and remain the job of the CI pipeline after the push. A pre-commit hook that tries to cover the entire test suite will sooner or later be disabled or bypassed with --no-verify, because it interrupts the development flow too much. Fast, targeted unit tests right at the change offer the best trade-off between safety and speed.

6. Speed: why slow hooks frustrate developers

A pre-commit hook with a noticeable wait quickly becomes the biggest source of friction in the daily workflow, because commits are typically created very frequently, often several times per hour. Even a hook that takes ten seconds adds up to substantial wasted time over a working day, and developers almost inevitably start looking for ways around it, such as git commit --no-verify. Once that reflex becomes a habit, the hook's actual protective function is effectively disabled.

The most effective countermeasure against slow hooks is to check only the staged files instead of the entire repository, as already described for linting and formatting. An explicit timeout that kills a hanging process after a few seconds also helps, rather than leaving the developer waiting indefinitely. Expensive checks like a full static analysis run across the whole project belong in the pre-push hook or the CI pipeline, not in the pre-commit hook that runs on every single commit.


#!/usr/bin/env bash
# pre-commit hook: bound total runtime and only touch staged files
set -euo pipefail

readonly TIMEOUT_SECONDS=8

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

if [ -z "$staged_files" ]; then
  exit 0
fi

# Wrap the whole check in a hard timeout so a hanging tool never blocks a commit
if ! timeout "$TIMEOUT_SECONDS" bash -c '
  files="$1"
  for f in $files; do
    case "$f" in
      *.php) php -l "$f" > /dev/null ;;
    esac
  done
' _ "$staged_files"; then
  echo "pre-commit checks exceeded ${TIMEOUT_SECONDS}s or failed, aborting commit."
  exit 1
fi

exit 0

7. Hand-rolled shell scripts: flexibility with maintenance cost

A hand-written shell script as a pre-commit hook offers unrestricted flexibility: any logic at all can be implemented directly in Bash, Python, or another language, without external dependencies. For small teams with a single tool stack, say PHP only, a compact, self-maintained script is often set up quickly and easy to follow, because the entire flow lives in a single, readable file.

As the number of tools and languages in the project grows, however, so does the maintenance burden: every new tool has to be integrated into the script by hand, including its own error handling, its own file filtering, and its own version management for the linters in use. In a team running different operating systems, subtle differences between Bash versions on macOS and Linux surface on top of that. This is exactly the point where it is worth looking at an established framework that has already solved this repetitive work.

8. The pre-commit framework as a language-agnostic multi-tool runner

The pre-commit framework, available at pre-commit.com, solves exactly the maintenance problem of hand-rolled scripts: it is itself language-agnostic, but manages the environments of individual tools automatically, regardless of whether a tool is written in Python, Go, Rust, or Node.js. Instead of manually installing and invoking every tool inside a script, a YAML file, .pre-commit-config.yaml, simply declares which hooks from which repository and version should be used.

On the first run, the framework automatically downloads the matching isolated environments for each tool and caches them, so later runs stay fast. Every team member is thereby guaranteed the exact same tool version, without needing a global installation on every machine. On top of that, the framework also handles automatic filtering to staged files, parallelizing multiple hooks, and a consistent, readable output, things that would each have to be implemented by hand in a hand-rolled script.


# .pre-commit-config.yaml, versioned in the repository, shared by the whole team
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict

  - repo: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer
    rev: v3.57.0
    hooks:
      - id: php-cs-fixer
        args: [--config=.php-cs-fixer.php]

  - repo: local
    hooks:
      - id: php-lint
        name: PHP syntax check
        entry: php -l
        language: system
        files: \.php$

# Install once per clone, then it runs automatically on every commit
# pre-commit install

9. Hand-rolled scripts vs. the pre-commit framework compared

Both approaches solve the same underlying problem, but differ significantly in maintainability, portability, and setup effort. The following overview summarizes the key differences.

Dimension Hand-Rolled Script pre-commit Framework
Language independence Must be integrated manually per tool Yes, uniform across all languages
Tool versioning Depends on local installation Pinned directly in the YAML file
Setup effort Low for a single tool One-time install, then automatic
Maintenance for new tools Manual integration into the script needed A new entry in the YAML file suffices
Cross-platform Bash differences between macOS/Linux possible Isolated environments per platform
Full control over logic Completely free Still possible via local hooks

For small, homogeneous projects with a single tool stack, a lean, hand-rolled script remains a legitimate choice. As soon as multiple languages, multiple tools, or a growing team come into play, the pre-commit framework significantly reduces the maintenance burden and ensures every developer uses the same tool versions, without anyone having to maintain the installation by hand.

Mironsoft

Git workflows, code quality and CI/CD automation for development teams

Pre-commit hooks developers actually use?

We set up fast, targeted pre-commit hooks for your team, from a lean shell script to the full pre-commit framework, with linting, formatting and fast tests that don't slow down the workflow.

Hook setup

pre-commit framework or hand-rolled scripts, matched to your stack

Linting & formatting

Automated checks and auto-fix right before the commit

Performance tuning

Making hooks fast enough that nobody wants to bypass them

10. Summary

Pre-commit hooks are the earliest meaningful checkpoint in the Git workflow, because they run before the commit even exists and can block it entirely on an error. Linting, automatic formatting, and fast, targeted unit tests cover most practical cases, as long as they consistently stick to the staged files. Speed is not a side issue here: a hook with a noticeable wait will sooner or later be bypassed with --no-verify, losing its actual protective function.

Hand-rolled shell scripts offer full control and work well for small, homogeneous projects. The pre-commit framework from pre-commit.com, by contrast, significantly reduces the maintenance burden once multiple languages and tools are in play, because it centrally manages environments, versioning, and file filtering through a single YAML file. The right choice depends less on personal taste than on the size and heterogeneity of the project.

Pre-Commit Hooks for Code Quality, the essentials at a glance

Earliest checkpoint

Runs before the commit is created and can block it entirely on a rule violation.

Worthwhile checks

Linting, automatic formatting with re-add, and fast, targeted unit tests on the staged files.

Speed matters

Check only staged files, set timeouts, move expensive checks to pre-push or CI.

pre-commit framework

Language-agnostic multi-tool runner via .pre-commit-config.yaml, solves the maintenance burden of hand-rolled scripts.

11. FAQ: Pre-Commit Hooks for Code Quality

1What exactly does a pre-commit hook do?
It runs after staging but before the commit is created. A non-zero exit code blocks the commit entirely.
2Which checks belong in a pre-commit hook?
Linting, automatic formatting, and fast unit tests on staged files. Slow integration tests belong in pre-push or the CI pipeline.
3Why only check staged files?
A full repository run on every commit would be impractical and undermines the point of a fast checkpoint.
4How long should a pre-commit hook take at most?
A few seconds. Longer leads almost inevitably to --no-verify, losing the protective function.
5Can formatting be auto-fixed?
Yes, tools like php-cs-fixer reformat directly. The hook re-adds the corrected files with git add.
6What is the pre-commit framework from pre-commit.com?
A language-agnostic multi-tool runner that declares hooks via a YAML file and manages tool environments automatically.
7When is a hand-rolled script worth it?
For small, homogeneous projects with a single tool stack, a lean custom script is often quicker to set up.
8How do I check the actually staged content?
With git show :file.php instead of reading the file from disk, important with partially staged files.
9Can I move unit tests entirely into it?
No, only fast, targeted tests. The full test suite remains the job of the CI pipeline after the push.
10How do I install the framework across a team?
The YAML file is versioned in the repository, each member runs pre-commit install once.