solo · automation

Put the review on a hook: fast on commit, full on push

Running a full review on every commit is a bad idea, and the arithmetic shows why: on our own runs the judge accounts for about half of the price. That is why the two hooks do different jobs. The commit hook is cheap and blunt — one model, no judge — and it stops you only for something you would never want in history at all. The push hook is the strict one, with the judge, and it stands where the code leaves your machine.

Below: how to set up both steps, which exit code blocks and which one lets you through, and why a hook without an explicit threshold blocks nothing at all.

Before you start: you need reviewgate working in your terminal and access to a model — a key of your own, your company's LLM gateway or a local model. If you have not run a first review yet, start with your first local review.

What --fast actually turns off

It is not «a shallower look». --fast changes who takes part in the run: only the first of your generators stays, the panel of judges is emptied and the arbiter is dropped. The stages that rely on the judges go with them: the judging pass, the full files gathered for the judge, the re-check of ready-made fixes, and the questions lens — the one that turns some of the dropped findings into questions to the author.

On our own runs that looked like this: a commit of 37 lines that passed — 12 seconds and about $0.01; the coupon branch on push, 31 lines in two files against main, stopped by the judge — about a minute and a half and $0.17. What the judge itself costs we measured separately, on one and the same diff: about half of the bill. That ratio is the whole argument for two steps instead of one.

Step 1. The commit hook: cheap, blunt, rarely in the way

terminal
# .husky/pre-commit
# free checks first — uncomment what you run:
# npx lint-staged
# npm test

# hooks should run on one agreed personal config, not on each developer's own.
# Applied only if the file exists, so a typo cannot disable the review silently:
# CFG="$HOME/.config/reviewgate/config.hooks.yml"
# [ -z "${REVIEWGATE_CONFIG:-}" ] && [ -f "$CFG" ] && export REVIEWGATE_CONFIG="$CFG"

command -v reviewgate >/dev/null 2>&1 || {
  echo "reviewgate: not installed — commit allowed unchecked" >&2
  exit 0
}

code=0
reviewgate review --staged --fast --fail-on blocker || code=$?
case $code in
  0) ;;
  2)
    echo "reviewgate: blocking findings — commit stopped" \
         "(bypass: git commit --no-verify)" >&2
    exit 1
    ;;
  *)
    echo "reviewgate: the review did not run (code $code)" \
         "— commit allowed unchecked" >&2
    ;;
esac
exit 0

Four decisions here, and they decide whether the hook stays or gets deleted within a week.

Important:

  • --fail-on blocker, not major. A draft commit should not be held hostage by a style remark. At this threshold the review still shows everything it found — 🟠 major included — but it stops you only for what you would never want in history at all: a secret or credentials in the diff, and irreversible destruction of data. Those are exactly the two classes the product treats as blocking unconditionally; everything else, a broken migration and a removed access check included, comes as 🔴 critical and does not stop the commit.
  • --fail-on at all. Without it the hook blocks nothing, ever. The gate is off by default, so the review exits 0 with any number of findings — and the hook, seeing a zero, lets the commit through. That is how you end up with a hook that prints findings and stops nothing.
  • Capture the exit code right away: || code=$?. husky runs the hook file with sh -e, so the shell stops at the first command that fails. A review line that returns a non-zero code therefore aborts the whole hook, and a case $? written after it never runs: you see neither your own message nor the handling of the codes.
  • Free checks first, the review after. The linter, the formatter and the tests are deterministic and cost nothing, and sh -e aborts the hook on the first failure, so the model is never called for nothing. || code=$? is needed on the review line only — the other commands may simply fail.

Step 2. The push hook: the strict one

terminal
# .husky/pre-push
# free checks first — uncomment what you run:
# npm run lint
# npm test

# hooks should run on one agreed personal config, not on each developer's own.
# Applied only if the file exists, so a typo cannot disable the review silently:
# CFG="$HOME/.config/reviewgate/config.hooks.yml"
# [ -z "${REVIEWGATE_CONFIG:-}" ] && [ -f "$CFG" ] && export REVIEWGATE_CONFIG="$CFG"

command -v reviewgate >/dev/null 2>&1 || {
  echo "reviewgate: not installed — push allowed unchecked" >&2
  exit 0
}

code=0
reviewgate review --refs origin/main --fail-on major || code=$?
case $code in
  0) ;;
  2)
    echo "reviewgate: blocking findings — push stopped" \
         "(bypass: git push --no-verify)" >&2
    exit 1
    ;;
  *)
    echo "reviewgate: the review did not run (code $code)" \
         "— push allowed unchecked" >&2
    ;;
esac
exit 0

Same shape, three differences: the whole branch instead of the index, the judge instead of a single model, and major instead of blocker — because this is the boundary where the code stops being only yours.

only exit code 2 blocksAny other non-zero code means the review itself did not run — no network, no key, a broken config — and the hook lets you through with a line in stderr. A review tool that locks you inside your own branch when it breaks is worse than no review tool. It is a fair trade, but know what you are trading: that line in stderr is easy to miss.

What it looks like in the terminal

Both branches are public in our sample repository: the one with the token on GitLab and GitHub, the one with the coupon on GitLab and GitHub.

A developer wires the invoice letter to a delivery provider and puts the token straight into the source. The commit hook does not let that into history:

terminal
⛔ src/notifications/notifications.service.ts:18 — A live-looking delivery provider
   token (mrt_live_...) is committed directly in source. Anyone with repo access (or
   anyone who ever sees this diff/history) can use it to send mail as this service, and
   rotating it requires a code deploy instead of a config change.
   Remove the literal token from source, load it via ConfigService/environment at
   runtime, and rotate the exposed token immediately since it is now in git history.

❌ Gate (blocker) FAILED. Findings: 4 (⛔ 1 · 🔴 0 · 🟠 2 · 🔵 1 · ⚪ 0).
⚙️ Run cost: ≈ 0.05 $
reviewgate: blocking findings — commit stopped (bypass: git commit --no-verify)
husky - pre-commit script failed (code 1)

$0.05 and about forty seconds. Note the counters: two 🟠 major findings and a 🔵 minor one were also shown — and they stopped nothing, because the threshold here is blocker. That is the point of the low bar on commit: you see everything, and you are stopped rarely, and only for a blocker.

The developer moves the token to the environment and commits again:

terminal
✅ Gate (blocker) passed. Findings: 3 (⛔ 0 · 🔴 0 · 🟠 2 · 🔵 1 · ⚪ 0).
⚙️ Run cost: ≈ 0.08 $
[feat/email-delivery 736eee1] fix: take the delivery credentials from the environment

The blocker left together with the token, and the commit goes through. Three findings are still on screen — two 🟠 major and a 🔵 minor — and one of them is new: the diff changed, and the model does not repeat itself from run to run. Nothing was swept under the rug; these are simply not a reason to block a draft.

Same repository, a different branch — the one where a coupon discount was written by hand instead of going through the shared money helper:

terminal
❌ Gate (major) FAILED. Findings: 3 (⛔ 0 · 🔴 1 · 🟠 2 · 🔵 0 · ⚪ 0).
⚙️ Run cost: ≈ 0.17 $
reviewgate: blocking findings — push stopped (bypass: git push --no-verify)
husky - pre-push script failed (code 1)
error: failed to push some refs

$0.17, and the branch stays on the machine until the author fixes what the 🔴 critical finding about money points at.

why the second step does not catch everythingThis chain has a probabilistic link. The model does not repeat itself: two runs over the same code give different sets of findings. That is why the two steps are not a sieve with an ever finer mesh: their scope differs (the index against the whole branch), and the full run on push can find fewer things than the fast one on commit — on our runs it found one where the commit hook had found three. What the ladder buys you is a cheap early stop on the crudest problems and a stricter check on push, not a guarantee that the second step catches everything the first let through.

What the ladder costs

Our own runs, same repository, 7 September 2026; prices per Anthropic's price list on 23 September 2026. The price also depends on the cache: the prompt is cached for an hour, and the first run within the hour costs more than the ones after it.

StepScopeTimeCost
commit, passedstaged diff, 37 lines12 s≈ $0.01
commit, stoppedstaged diff, one file~40 s≈ $0.05
push, passedmain, two new commits~60 s≈ $0.06
push, stoppedcoupon branch vs main~90 s≈ $0.17

Four things that break the ladder

A lockfile will inflate your bill. Our first hook run cost $0.33 on a diff of 37 lines, because pnpm-lock.yaml went into the model context whole: 143 962 bytes of the 146 179 the model read, which came to 96 thousand tokens of input for four changed files — a file of hashes packs into tokens far denser than prose does. With the lockfile in ignore the same commit came to $0.01: more than twenty times cheaper and ten times faster. Put lockfiles, generated code and fixtures in ignore before you put the hook on a commit, not after.

git push --dry-run runs the hook. We checked: a dry run fires pre-push exactly like a real one, so «just seeing what would happen» costs a full review with the judge. --no-verify suppresses it.

A developer's personal config wins over the team's. The home schema replaces the team roles rather than filling gaps, so a colleague whose personal config points at a different provider runs your team's review on their model — or, if the key does not fit, gets a failing run that the hook lets through with a line in stderr that is easy to miss. If your hooks must run on the team's schema, set REVIEWGATE_CONFIG in the hook to the path of the file you need: it overrides the home config. Our own hook does exactly that, and it sets the path only if the file exists: pointed at a missing file, ReviewGate decides there is no personal config at all, the run fails with llm_unavailable, and the hook lets you through.

--no-verify is a regular way out, not a break-in. Anyone can skip both hooks, and people will — on a hotfix at midnight, on a revert, on a branch that is already broken. Local hooks work only until someone skips them. The check nobody can skip runs on GitLab or GitHub: the bot on the merge or pull request, which is set up separately.

When it goes wrong

The hook never blocks anything. Almost always the missing --fail-on: the gate is off by default, the review exits 0 with any findings, and the hook gets a zero and lets the commit through. Check by running the same command by hand and looking at the exit code — a blocked run gives 2:

terminal
reviewgate review --staged --fast --fail-on blocker; echo $?

The hook does not run at all — no output, no delay. Git does not know about it: husky writes the hooks path on install, and only where prepare ran. On a fresh clone, pnpm install puts the hooks in place. To check, git config core.hooksPath must print .husky/_.

A commit hangs for minutes. Something large is going into the model context — most often a lockfile, a snapshot or generated code. Find the Judge context line in the run's output: it names the number of files and characters. The cure is ignore.

stderr says «the review did not run (code N)». The review itself broke — no key, no network, a config it could not read — and the hook let you through on purpose. Your commit or push went unchecked. Run the command by hand to see the real error; the exit code will name the class of failure.

A commit made by an AI agent is cut off by a timeout. Agents run commands under their own time limit, and a full review may not fit in it. Then there is no commit at all: the changes stay in the index, while the run that started keeps going in the background, and you pay for it. The agent, seeing the error, may well retry the commit with --no-verify — and that is when the code goes in unchecked. That is why the commit hook stays on --fast: without the judge the run is noticeably shorter.

Next