Pre-commit hooks vs CI: which checks belong where

Pre-commit hooks vs CI, compared. When one person pushes, the hooks are the gate and CI is a second opinion. A team flips that. See which checks go where.

Expedition Labsupdated 6 min read

The short answer to pre-commit hooks vs CI depends on how many people push. When one person does, alone or with coding agents, the hooks are the gate and CI is a second opinion that runs after the push. When a second person can push, CI becomes the gate and the hooks become a fast preview of it. Most advice assumes the team case, and a solo repository ends up paying for a pipeline it does not need.

The rule that sorts the checks is the same in both cases. A check has to run without anyone remembering to run it, in a place the person pushing cannot quietly skip. Alone, that place is your own machine. On a team, it is a hosted runner.

What a local hook cannot do

A hook runs on the machine that made the commit, and three things skip it. One is the bypass flag. Another is a machine that is missing part of the toolchain. The third is any commit made outside a normal working tree, such as one made in the host's web editor.

When every push comes from one machine that you set up, you control all three. The toolchain is there, you do not use the web editor, and the bypass flag is ruled out in writing, in the instruction file your coding agent reads. Once someone else can push, you control none of them, and a hook stops being proof of anything.

What CI adds when you work alone

Your machine has state that a fresh checkout does not: a file on disk that was never staged, packages the lockfile no longer describes, a local environment file, a warm build cache. A hosted run starts from nothing, so it catches the change that only works where it was written. One workflow on each push to the default branch is enough. It calls the same scripts as the hooks and blocks nothing. If a deploy platform already builds every push from a clean checkout, you have most of this already.

Why the hooks have to stay fast

A hook that is slow enough to annoy you gets bypassed, and a bypassed hook with no gate behind it means zero verification. So split the work by cost. The pre-commit hook runs typecheck, read-only lint and unit tests, cheapest first, and keeps every commit sound. The production build is slower, so it runs in the pre-push hook. A push happens a few times a day and is the moment the work reaches the deploy platform. A minute there is a fair price.

If the commit checks grow past a few seconds on a large project, narrow them before you drop them. Lint and test only what the staged files touch, and move the whole-project run to the pre-push hook.

Which check goes where

CheckPre-commitPre-pushHosted CIWhy
TypecheckYesNoAfter the pushEvery commit should be sound. Generate route and schema types in the same command.
Lint (check mode)YesNoAfter the pushAn auto-fix in a gate repairs the violation, reports success, and throws the repair away.
Unit testsYesNoAfter the pushKeep the suite fast enough that the hook is never worth skipping.
Format check (check mode)YesNoAfter the pushThe check reports unformatted files. Only a person runs the command that rewrites them.
Production buildNoYesAfter the pushA build costs too much for a commit. Typecheck, lint and tests do not exercise it.
Suites that need live shared infrastructureNoNoOn a scheduleA gate that goes red from network flakiness teaches you to ignore red.

Two rows need a note. The lint row is about mode. Keep two scripts, one that checks and one that fixes. Every hook and runner calls the checking one. Only a person runs the fixing one. An auto-fix in a pre-commit hook that nothing re-stages lands on disk only, so the committed snapshot is not the snapshot that passed.

The last row is about trust. A suite that needs live external services belongs on a nightly schedule and a manual trigger, with its report kept. Put it on the gate only once it runs against disposable infrastructure that the check itself starts.

The setup, step by step

  1. Install a hook manager that installs its hooks on every dependency install, so a fresh clone has them without a setup step.
  2. Split lint into two scripts, such as lint and lint:fix. Point every automated path at lint.
  3. Give the pre-commit hook the typecheck, the read-only lint and the unit tests, in that order.
  4. Give the pre-push hook one line: the production build.
  5. Rule out the bypass flag. Write it into the agent instruction file, and deny the flag in the agent's permission settings if the tool has them.
  6. Prove each hook once. Commit a type error and push a build error, and watch both get refused.
  7. Add one workflow on a hosted runner that runs on each push to the default branch and calls the same scripts. Use read-only permissions, pin third-party steps to a commit hash, and install from a frozen lockfile.
  8. Move any suite that needs live shared infrastructure to a nightly and manually triggered run.

Step five is the one that gets forgotten, and it matters most with agents. An agent that hits a red hook late in a task will reach for the flag unless something stops it.

Be honest about what a local build proves. The host installs clean, pins its own runtime version and injects its own environment. A local run can pass on a stale output directory or an environment file the host never sees. Pin your runtime to the host version to narrow the gap, and let the first clean build after the push cover the rest. Each of these rules is written up in full in our engineering practices.

When a second person pushes

The setup above holds while every push comes from a machine you set up. The day someone else gets push access, promote the hosted run to a gate. Run it on pull requests, split it into named jobs, and add each job to the required status checks on the default branch. That is usually a repository setting, so a person has to apply it. Open one pull request that fails on purpose and confirm the merge is refused. A check name with a typo is required of nothing.

Until then, branch protection on a repository only you push to adds a pull request and a wait to every change, and catches nothing the hooks did not.

Why this matters more with coding agents

In agentic engineering, checks in the repository block a change that breaks a rule before a person reads it. That only works if the check cannot be skipped. An agent that commits from your machine runs your hooks, so the hooks hold as long as the bypass flag is closed to it. An agent that runs in the cloud and opens pull requests is a second pusher, and it needs required status checks on a hosted runner.

Either gate is one of the guardrails for AI coding agents. A green gate does not prove a feature works. How to verify AI generated code covers that.

The rule to remember

Put the gate where the person pushing cannot skip it. Alone, that is the hooks: correctness checks at the commit, the build at the push, and no bypass flag. On a team, it is required checks on a hosted runner. Keep flaky suites off the gate so that red always means something.

  • engineering practices
  • comparison

keep reading

get in touch

Let's scope your project.

Tell us what you need and where things are stuck. You'll hear back within a day with a plain-language plan and a price. We skip the pitch deck and the discovery retainer.