- Authors

- Name
- Nadim Tuhin
- @nadimtuhin
On this page
Our monorepo has three layers of gates. Every file an AI agent edits passes through a hook that refuses new prose comments. Every push lints, typechecks, and runs the hooks' own test suites. CI runs it all again. Each gate earned its place, and together they made editing sluggish and pushing slow.
I expected to rewrite everything in a faster language. The profiles showed that the hooks spent almost none of their time checking code. It went into starting Node, spawning git and loading compilers, on every edit and every push. The linter really was slow at its work, and CI was limited by how many runners it had.
The numbers
| Gate | Before | After |
|---|---|---|
| Comment gate, per edit | 210ms | 15ms |
| Hook test suites, on a push touching hooks | 67s | 8s, crept back to 21s |
| Lint pass, per side of the warning ratchet | 21s | 0.7s |
| Comment-density nudge, per edit | 690-870ms | about 60ms skipped, 160ms when it warns |
A 210ms hook doing 5ms of work
The comment gate compares the comments in a file before and after an edit and blocks any new ones. That is trivial work. Yet every edit cost 210ms, and almost all of it was overhead: starting Node, loading the TypeScript compiler to get a scanner, and spawning git rev-parse to find the repo root.
First, squeeze Node. Two changes, no new language:
module.enableCompileCache(), so the TypeScript library loads from cached bytecode instead of being compiled on every run.- Find the repo root by walking up the directory tree to
.gitinstead of spawning git.
That halved it, to 107ms. If you stop here, you get half the win for an afternoon's work.
Then, port it to Rust. oxc ships a JavaScript and TypeScript parser as a Rust crate. A prototype parsed all 2,639 tracked files in the repo with zero errors. The port keeps the same exit codes and messages and runs as a 2MB binary in about 5ms.
A compile step has no place in an editor loop, so a small POSIX shell launcher sits in front of the binary. It keys the binary by a checksum of the crate's source, runs it if it exists, and otherwise starts one background build:
#!/bin/sh
crate=$here/no-new-comments
cache=${XDG_CACHE_HOME:-$HOME/.cache}/repo-hooks
source_key() {
sum=$(cat "$crate/Cargo.toml" "$crate/Cargo.lock" "$crate"/src/*.rs | cksum)
echo "${sum% *}-${sum#* }"
}
key=$(source_key)
bin=$cache/no-new-comments-$key
[ -x "$bin" ] && exec "$bin"
# ... start one background cargo build, guarded by a lock directory ...
echo "no-new-comments: building hook binary, gate off for this edit" >&2
exit 0
Through the launcher, an edit now costs about 15ms.
The trade is that the gate is off while the binary builds. That happens only on a fresh machine or after someone edits the gate itself. The first build takes about 40s, and a failed build says so and waits an hour before retrying. We accepted that. A team that can't would fall back to the Node gate during the build.
Trusting a rewrite: replay history
Green unit tests written by the same person who wrote the port prove only that the port agrees with its author. To find out whether it agrees with reality, we replayed history. Every source-file change in the last 150 commits became a before-and-after write, 509 in total. Both gates judged each one, and we diffed the verdicts.
They disagreed 20 times, and the Rust gate was right every time. The old gate's token scanner lost sync on regex and template literals: it falsely blocked 6 strings that merely contained //, and it missed 14 real comments. We checked each disagreement by hand.
An independent review then caught two bugs the tests hadn't:
- An unparseable file turned the gate off. Wrapping code in a
trytakes two edits, and the file doesn't parse in between. A comment added in the second edit slipped through. - A shared cargo target directory could install the wrong binary. Every worktree built the same package into one shared directory. Cargo saw a fresh build and left the previous output in place, so the launcher could copy another worktree's binary under its own key. The fix bakes the key into the binary and checks it before installing.
The second bug generalises: if a build cache is keyed by content, put the key inside the artifact and verify it.
The push: 4,551 git processes
On a push that touched hook code, the hook test suites took 67s. After the Node changes above, they took 8s.
The obvious suspect was the other change in the same commit, which ran the suites in parallel instead of one after another. So I re-ran every suite both ways on the commit before and the commit after:
| Commit | Serial | Parallel |
|---|---|---|
| Before the change | 58.1s | 54.9s |
| After the change | 11.6s | 7.3s |
Parallelism was worth 3 to 4 seconds. Nearly all of the rest was one suite, the comment gate's own, falling from 54.1s to 7.4s.
That suite runs the gate in-process over all ~2,600 tracked TypeScript files. Every call spawned git rev-parse once, and a second time for files containing comment markers: 4,551 git processes per run, roughly 10ms each. The directory walk removed the first spawn, and deferring the second lookup until it was needed removed nearly all of the rest.
A fix you make for the per-edit path pays off again anywhere that path runs in a loop, and a test suite is exactly such a loop.
It has since crept back to about 21s. The Rust gate's test suite builds a deliberately broken copy of the gate to prove its tests can fail, and it builds that copy in a fresh temporary directory, so cargo starts cold every time. Caching that build is the next fix.
Lint: 21 seconds to 0.7
Our pre-push gate is a warning ratchet: it lints your branch and its merge base, and refuses the push if the warning count went up. A slow linter costs you twice. ESLint took about 21s per side. oxlint takes 0.7s.
oxlint isn't a perfect clone, so we measured the drift before switching. ESLint reported 1,110 warnings and oxlint 1,123. Eleven rules matched exactly, including complexity, max-lines-per-function, max-statements, max-lines and no-unused-vars. The rest:
| Rule | oxlint vs ESLint |
|---|---|
max-depth | +19 |
no-useless-assignment | -4 |
react-hooks/exhaustive-deps | +1 |
| Unused eslint-disable directives | -3 |
Because the ratchet lints both sides with the same tool, the drift shows up on both and never reads as a regression. Three gotchas cost us time:
- A custom plugin must export
meta: { name }, or oxlint reports "Plugin not found". - oxlint doesn't read ESLint's
ignores, and it lints a file you name explicitly even when the config ignores it. Filter paths yourself. - Its
correctnesscategory isn't ESLint's recommended set. Turn the categories off and pin each rule.
oxlint can also run custom ESLint rules through jsPlugins. We had a second per-edit hook that ran the full ESLint config just to evaluate one custom comment-density rule: 690 to 870ms per edit. With oxlint running only that rule, behind a cheap pre-check that skips short or lightly commented files, a skipped edit averages about 60ms (30 runs on a 979-line file), of which about 45ms is Node starting. An edit where the rule runs and warns takes about 160ms. Across every tracked file it reports the same 106 warnings ESLint does.
The typechecker we didn't adopt
tsgo, the Go port of the TypeScript compiler, typechecked the repo in about 4s against tsc's 16.6s, and matched tsc's diagnostics exactly on a planted-error test. But it needed its own tsconfig, and the build was a 7.0.0-dev preview. We kept tsc and ran it less often instead.
CI: the bottleneck is the runners
CI runs on two self-hosted runners that share one 4-core VM. We made it do less: manual runs on feature branches now run only the suites whose paths changed, skip coverage, and cancel any stale run on the same branch.
Then I pulled per-job timings from 24 runs, and the bigger picture was capacity. The test jobs don't depend on each other, but only two can run at once. A full push needs about 1,270 runner-seconds, so two runners can't finish it in under about 640s, and real runs took between 722s and 1,263s. A job waited a median of 60 to 100s for a free runner, and one run queued for 13 minutes before its first job started.
On a setup like that, cutting 60 seconds from one job saves roughly 30 on the clock. Removing duplicated work, such as the same dependency install repeated in three jobs, or adding a runner, moves the floor itself.
What it cost
- Building the gate now needs a Rust toolchain. Without cargo, the gate is off and says so.
- Lint-count drift has to be re-checked on every oxlint upgrade.
- The new code produced the worst bugs in this post, and review caught them, not tests.
- If your hook runs rarely, or your team doesn't write Rust, stop at the Node changes. They got half the win.
What I'd tell another team
- Time a gate's startup separately from its work. If startup dominates, change the runtime; if the work does, change the algorithm.
- Multiply per-call cost by call count. A 10ms process spawn inside a loop over 2,600 files is the whole budget.
- When one commit makes two changes, measure each on its own before crediting either.
- Validate a rewritten gate by replaying real history through the old and new versions and diffing the verdicts.
- On shared runners, count runner-seconds, not job durations. Wall-clock follows capacity.