· 5 min Automate

A local hook would have caught one problem in five

Pre-commit Developer Experience CI/CD Git SpotBugs Secrets

On April 8 I released encryption at rest for secrets in keycloak-webhook-provider. At the first commit of the series master was green. Then 38 commits piled up locally, never pushed. At release push, CI reported four broken e2e tests and one SpotBugs error.

None of the five problems was a regression from the release commit. They had been born in the intermediate commits, which CI had never seen: a push event produces a single run, on the HEAD of the ref. All 38 commits were validated together, at the moment an error costs the most.

The question a team should ask is how many of those five problems a local hook would have caught before the push.

A local hook would have caught one problem in five

The SpotBugs error was DMI_RANDOM_USED_ONLY_ONCE: a new SecureRandom() created to generate a single value and then discarded. With SpotBugs running before the commit, it would have surfaced at the commit that introduced it.

The e2e tests failed for different reasons. The selector getByRole('radio', { name: '10' }) in the 06-settings test matched three elements instead of one. It was not flaky: it had been broken since the day a second radio group with the label “10” was added. The WEBHOOK_ENCRYPTION_KEY variable was required by the provider but not propagated by the docker-compose of the e2e tests. Code and fixtures live in different folders, and the commit that introduced the requirement did not touch the tests.

A hook that looks at staged files sees neither case. The selector breaks when the page changes. The variable goes missing when provider and compose drift apart. Seeing them takes a running page and a started stack.

Pre-commit holds what can be decided on a single file

Formatting, fast lint rules and secret scanning have a binary outcome: they pass or they fail, with no interpretation. They need no context beyond the staged file, and they cost a few seconds.

CheckWhereTypical toolsReason
Formattingpre-commitprettier, gofmt, ruff format, spotlessDeterministic, zero false positives
Fast lint rulespre-commiteslint --cache, ruff check, golangci-lint --fastRules that look at one file at a time
Secret scanningpre-commitgitleaks, detect-secretsA secret in the history is costly, the check is cheap
Static analysis on bytecodepre-pushspotbugsNeeds a compile
Unit testspre-pushpytest -x, mvn testToo slow for every commit
e2e smokepre-pushdocker compose, playwrightNeed the stack running
Type check, full build, dependency auditCImypy, tsc --noEmit, pip-auditHeavy, and best run with the full context

The rule for telling them apart: if the fix requires reading the output and interpreting it, the check does not belong in pre-commit.

Pre-push holds what needs a compile or the running stack

SpotBugs analyzes bytecode, so it needs a compile. The e2e tests need docker compose. They are too slow for every commit and acceptable once per push.

# file: .pre-commit-config.yaml
default_install_hook_types: [pre-commit, pre-push]
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.24.2
    hooks:
      - id: gitleaks

  - repo: local
    hooks:
      - id: spotbugs
        name: SpotBugs
        entry: mvn -q compile spotbugs:check
        language: unsupported
        pass_filenames: false
        stages: [pre-push]

      - id: e2e-smoke
        name: e2e smoke
        entry: make e2e-smoke  # Makefile target: starts compose, runs the minimal tests
        language: unsupported
        pass_filenames: false
        stages: [pre-push]

Pre-push does not change the distance between the cause and the failure. With 38 local commits, one push produces one run, and the five problems still surface together, in the terminal instead of in CI. The hook decides where the problem is found, the push frequency decides when.

Shortening the distance takes both: fast checks in pre-commit, and a push after every finished unit of work.

Local hooks can be skipped, CI cannot

git commit --no-verify, git push --no-verify and SKIP=spotbugs git push exist. A hook is a guardrail for whoever installed it, not a guarantee for the repo. The guarantee lives in CI, which runs the checks on every push. Formatters go through pre-commit run, static analysis and tests are ordinary pipeline steps:

# file: .github/workflows/ci.yml (excerpt)
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install pre-commit
      - run: pre-commit run --all-files --show-diff-on-failure
      - run: mvn -q verify  # SpotBugs and tests, as ordinary steps

--show-diff-on-failure prints the diff the formatter would have applied, so whoever skipped the installation sees right away what to fix.

Installation has to be automated, otherwise hooks exist only on the machines of the people who remember them:

# file: Makefile
.PHONY: install-hooks
install-hooks:
	pre-commit install

With default_install_hook_types in the configuration, a single pre-commit install activates both pre-commit and pre-push. The target belongs in the README, right after git clone.

The rule

A check belongs at the point closest to the cause where it can run.

WhereWhatCriterion
pre-commitFormatting, fast lint, secret scanningBinary outcome on one file
pre-pushStatic analysis, unit tests, e2e smokeNeeds a build or the stack
CIFormatters, static analysis and tests as pipeline steps, plus type check, coverage and auditGuarantee for the repo

A problem found by the person who just wrote the code is fixed with a change to the current commit. One found on release day takes a bisection across every intermediate commit.

Which CI failure last month was visible in a single file?

References

What remains open

The concrete configuration depends on your stack. The case shows the criteria, not a ready-made file.

  • — Time budget for pre-push hooks: depends on how long the project's build and tests take
  • — Monorepos: running hooks only on the packages the commit touches
  • — Mixed languages: how to keep the configuration from growing with every stack
Francesco Montelli
Francesco Montelli
Freelance Software Engineer

Freelance Software Engineer. I design, build and automate product software: from architecture to the build, to the processes that keep quality high over time. From scratch or on systems grown rigid over time.

Follow me on LinkedIn

Related articles

Edit on GitHub