A 90-second git commit is not a mystery. It is a budget you never wrote down. Somewhere between the first hook and the last, your commit path accumulated work that has nothing to do with committing: type checking, unit tests, secret scanning, dependency resolution, and a linter that re-reads the entire repository because nobody scoped its files pattern. Each addition was individually defensible. The sum is a tax on every save-and-commit cycle, paid by every engineer, on every branch, including the ones that will never be pushed.
This article is about treating the commit path as a budget with line items, and moving the expensive line items to CI where they belong. The argument is contrarian in one specific way: the default advice to “run everything locally so CI stays green” is backwards for most checks. Local hooks are for fast, deterministic, file-scoped checks. Everything else is cheaper in CI, and the cost of a slow commit is not the 90 seconds — it is the behavior the 90 seconds produces.
What Git actually guarantees about hooks
Start with the contract, because most hook performance problems are contract violations. Git’s own documentation describes pre-commit as invoked by git commit, bypassable with --no-verify, taking no parameters, and running before the commit message is obtained. A non-zero exit aborts the commit. The default pre-commit hook shipped with Git does two things: it prevents non-ASCII filenames and rejects lines with trailing whitespace. That is the entire intended scope of the default hook — a whitespace and filename check, not a test suite.
The ordering matters for budgeting. prepare-commit-msg runs after the default log message is prepared and before the editor starts; it is not suppressed by --no-verify, and a non-zero exit aborts the commit. commit-msg runs after the message file exists, is bypassable with --no-verify, and can edit the message in place. The default commit-msg hook detects duplicate Signed-off-by trailers. None of these hooks are described by Git as a place for build or test work. The documentation frames them as points where you can inspect, normalize, or refuse — not as a build system.
Two operational details from the same documentation are worth putting in your design doc. First, before Git invokes a hook it changes the working directory to the root of the working tree (or $GIT_DIR in a bare repository), and exports environment variables like GIT_DIR and GIT_WORK_TREE. A hook that shells out to another repository must clear those variables or it will operate on the wrong tree. Second, hooks without the executable bit are silently ignored. Both of these are common sources of “the hook works on my machine” incidents that get misattributed to performance.
The pre-commit framework’s cost model
The pre-commit framework is the most common way teams end up with a 90-second commit, and its documentation is unusually honest about why. It is a multi-language package manager for hooks: you declare repositories and hook IDs in .pre-commit-config.yaml, and the framework clones each hook repository, builds an isolated environment per hook, and runs the hook against the files that changed. The documentation states plainly that “the first time pre-commit runs on a file it will automatically download, install, and run the hook” and that “running a hook for the first time may be slow” — the example given is downloading and building a copy of Node if the machine does not have it.
That is the cold-start line item. It is not a bug; it is the design. The framework’s value proposition is that you can run a Ruby linter in a Node project without adding a Gemfile, and the price is per-hook environment construction. In a repository with a dozen hooks across Python, Node, Go, and a Docker-based scanner, the first commit after a clone can take minutes. Subsequent commits reuse the environments, so the steady-state cost is the sum of each hook’s execution time against the changed file set — plus the framework’s own overhead of resolving which files match which hook’s files, types, and exclude patterns.
Three configuration choices dominate steady-state time, and all three are documented:
- File filtering. Hooks run on changed files by default, but a hook with a broad
filespattern (or no pattern) will be handed more files than it needs. A type checker that receives every changed file in a monorepo is doing work the commit did not require. - Serialization. Hooks run in parallel by default;
require_serial: trueforces a single process. That flag is sometimes necessary for correctness, but it converts a parallel budget into a serial one. - Environment scope.
additional_dependenciesandlanguage_versionoverrides change what gets installed and rebuilt. Pinning a language version that differs from the developer’s system version means the framework builds and maintains a separate environment.
The framework also supports stages, which lets you confine a hook to pre-push or commit-msg instead of pre-commit. This is the single most underused lever in the configuration file. A hook that takes 20 seconds and only needs to run before code leaves the machine belongs in pre-push, not pre-commit.
A time budget you can actually write down
Here is a budget structure that works for repositories in the 20–200 engineer range. The numbers are targets, not research findings — they are the thresholds at which the commit path stops being a feedback loop and starts being a queue. Set them, measure against them, and move anything that blows the budget.
| Stage | Budget | What belongs here |
|---|---|---|
pre-commit |
< 3 seconds | Trailing whitespace, end-of-file fixer, YAML/JSON syntax, merge conflict markers, large-file check, secret scan on changed lines only |
commit-msg |
< 1 second | Conventional commit format, ticket reference, sign-off trailer |
pre-push |
< 30 seconds | Formatters and linters on changed files, fast unit tests for touched packages, type check on changed packages |
| CI (on push) | whatever the SLA says | Full lint, full type check, full unit suite, integration tests, secret scanning across history, dependency audit, build |
The 3-second pre-commit budget is aggressive on purpose. Below roughly 3 seconds, a hook is indistinguishable from the commit itself; the developer does not context-switch. Above roughly 10 seconds, they do — they check a message, open a browser, start a conversation. The cost is not linear in wall-clock time; it is a step function at the point where attention leaves the terminal.
The pre-push budget is where most teams should be spending their local compute. A push is a deliberate act with a natural pause; 30 seconds is tolerable in a way that 30 seconds at commit is not. The pre-commit framework’s stages key exists precisely to make this split easy, and Git’s own hook set includes pre-push as a first-class hook that receives the refs being pushed on stdin.
What moves to CI, and why it is cheaper there
The case for moving a check to CI is not “CI is faster.” CI is usually slower in wall-clock terms. The case is that CI runs once per push instead of once per commit, on a machine the developer is not waiting on, with caching and parallelism that a laptop cannot match. Three classes of checks belong there.
Checks whose cost scales with repository size, not change size. A type checker that needs the full project graph, a linter with cross-file rules, a build that needs all dependencies resolved — these do not get cheaper because you changed one file. Running them at commit means paying the full cost for a partial change. Running them in CI means paying it once per push, and CI can cache the dependency graph between runs. GitLab’s pipeline efficiency documentation is explicit about this: caching dependencies that change rarely, using smaller purpose-built images instead of one large image, and moving fast-failing jobs like syntax and style linting into an early stage so the pipeline fails before expensive jobs start.
Checks that need a clean environment. npm ci exists because npm install is not reproducible in the way CI needs. The npm documentation states that npm ci requires an existing lockfile, exits with an error if the lockfile and package.json disagree, removes any existing node_modules before installing, and never writes to package.json or the lockfile. That is a CI-shaped command. Running it in a pre-commit hook means deleting and rebuilding node_modules on every commit, which is exactly the kind of work that produces a 90-second commit and a developer who reaches for --no-verify.
Checks that are advisory or slow-failing. Secret scanning across full history, dependency vulnerability audits, license checks, and integration tests against real services are not commit-time checks. They are push-time or merge-time checks. The GitLab documentation’s guidance on failing fast is relevant here: a job that takes a long time to complete keeps the pipeline from returning a failed status until it finishes, so slow checks should not block fast feedback.
The CI side of the budget
Moving work to CI does not make it free; it moves the cost to a different budget line. GitHub’s documentation on hosted runners describes the model: with the exception of single-CPU runners, each GitHub-hosted runner is a new virtual machine, and single-CPU runners are hosted in a container on a shared VM. That means every job starts from a fresh machine image unless you use larger runners with custom images, which the documentation describes as a way to pre-configure environments and reduce setup time. The cold-start cost you removed from the developer’s laptop reappears as job setup time in CI — but it is paid once per push, in parallel, and it does not block a human.
Two CI-side facts belong in your capacity planning. First, GitHub’s documentation states that if a workflow run has been successfully queued but not processed by a hosted runner within 45 minutes, the queued run is discarded. That is a hard ceiling on queue depth, and it means a saturated runner pool does not just slow down — it drops work. Second, the same documentation notes that workflow runs triggered while GitHub Actions services are unavailable are discarded if not queued within 30 minutes. Neither of these is a reason to avoid CI; both are reasons to keep the CI job count and duration under control rather than treating CI as an infinite sink for every check you removed from the commit path.
Incremental execution changes the calculus
The strongest argument for moving checks to CI is that modern build systems make CI incremental in a way that local hooks are not. Bazel’s build documentation describes the model directly: after a build, running the same command again produces a “null build” because nothing changed, and if something changed in a target or its dependencies, Bazel re-executes only the affected actions. The documentation shows a first build at 9.9 seconds and a null build at 0.144 seconds for the same target. That is the difference between a check that costs full price every time and one that costs full price only when its inputs change.
Bazel also documents a repository cache shared across workspaces and Bazel versions, keyed by SHA256 of downloaded files, which avoids re-fetching the same external dependency. The implication for the commit path is direct: if your CI can cache the dependency graph and re-run only affected targets, then a check that would cost 40 seconds on every local commit might cost 2 seconds in CI on most pushes. The local hook cannot make that trade because it has no shared cache and no action graph.
This is not an argument for Bazel specifically. It is an argument that the decision of where a check runs should be made against the check’s incremental cost in each environment, not against a general principle about local versus remote. A check with a good cache and a fine-grained dependency graph is cheap in CI and expensive locally. A check with no cache and a coarse dependency graph is expensive everywhere, and belongs in neither place until someone fixes its inputs.
Measuring the budget instead of arguing about it
You cannot manage this budget without numbers, and the numbers are easy to collect. Three metrics cover most of it:
- Commit latency. Time from
git commitinvocation to shell prompt return, sampled across the team. The pre-commit framework prints per-hook duration in its output, which gives you the breakdown for free. If you are not using the framework, wrap each hook in a timer and log to a file. - Bypass rate. How often
--no-verifyappears in shell history or in a wrapper script. Git’s documentation confirms thatpre-commit,pre-merge-commit, andcommit-msgare all bypassable with--no-verify, and thatprepare-commit-msgis not. A high bypass rate on a bypassable hook is a signal that the hook is over budget; a bypass attempt onprepare-commit-msgis a signal that someone is confused about the contract. - CI queue time. Time from push to first job start, and time from job start to first failure. GitLab’s documentation recommends monitoring job and pipeline duration and using the API to collect metrics for long-term SLA analysis. GitHub’s 45-minute queue discard rule gives you a hard threshold to alert against.
Publish these numbers where the team can see them, alongside the budget. The point is not surveillance; it is that a hook that costs 12 seconds is invisible until someone puts it next to a 3-second budget and a bypass rate.
What to do when two options are within measurement noise
Some decisions in this space do not matter. Whether a formatter runs in pre-commit or pre-push is often within noise if the formatter is fast and file-scoped. Whether a secret scanner runs on changed lines locally and full history in CI is a real distinction; whether it runs as a Python hook or a Docker hook is usually not, once the environment is warm. When two configurations are within the variance of your measurements, pick one, write down why, and move on. The budget is the artifact; the specific tool is not.
The one decision that is never within noise is whether a check runs on every commit or once per push. That is a multiplier on every other cost, and it is the decision most teams make by accident.
FAQ
Is --no-verify always a problem?
No. Git documents it as a supported bypass for pre-commit, pre-merge-commit, and commit-msg. It is a problem when it becomes the default path because the hooks are over budget. Track the rate, not the individual use.
Should I run tests in pre-push instead of CI?
Only the fast, scoped ones. A pre-push hook that runs unit tests for the packages you touched is a reasonable 30-second budget. A pre-push hook that runs the full suite is a CI job wearing a local costume, and it will be bypassed.
Why does the first commit after a clone take so long?
Because the pre-commit framework builds an isolated environment per hook on first run. Its documentation states this explicitly and notes that running a hook for the first time may be slow. Subsequent commits reuse the environments. If your team clones frequently, consider a setup script that runs pre-commit install-hooks as part of onboarding rather than letting the first commit absorb the cost.
Does moving checks to CI just move the wait?
It moves the wait from a blocking, per-commit position to a non-blocking, per-push position, and it lets the check run in parallel with other work. The developer is not waiting on CI the way they wait on a commit hook. The trade is real but favorable, provided CI queue time stays bounded — which is why GitHub’s 45-minute queue discard rule and GitLab’s pipeline duration monitoring belong in the same design doc as the hook budget.
How do I know which hooks are over budget?
The pre-commit framework prints per-hook duration in its output. If you are not using it, wrap each hook in a timer. Compare against the budget table above. Anything over budget either gets scoped down, moved to pre-push, or moved to CI.