Yann M. Vidamment · blog

Your CI shouldn't be your formatter

1,158 words 6 min read

I had made my CI strict. Formatting, linting, type-checking, all of them blocking: a red check now stops a merge instead of printing a warning nobody reads. It felt responsible.

Twenty minutes later I edited the README and the whole pipeline went red. The culprit was a single line that started with a + and a space. In Markdown that is a bullet, and prettier rewrites bullets to one style (-). The format check asks a single question, does every file already match what prettier would write, and now one file did not.

The check worked. It flagged the problem after I had pushed, not before I committed.

The feedback loop is the problem

Fixing that, with the formatter living only in CI, goes like this. I push. A runner spins up somewhere. It installs dependencies, runs the checks, and a few minutes later tells me a file isn’t formatted. I run the formatter on my machine, look at the diff (one space), commit, and push again. Then I wait for the runner a second time.

That is minutes of round-trip, twice, for something a machine could fix in milliseconds before the commit exists.

Two different jobs share the name “formatting.” Rewriting the code so it conforms is one of them, and a tool does that for you in milliseconds. Checking that it already conforms is the other, a plain yes-or-no. CI does the check well and the rewrite badly, because each rewrite in CI costs a full push-and-wait cycle.

Move the rewriting to where it is cheap: your machine, the moment you commit. Leave CI as the verifier, a safety net that catches nothing as long as the system in front of it works.

Left: with the formatter in CI only, a failed check sends you back to commit and push again, a loop. Right: with the formatter in a pre-commit hook, commit, push, and CI verification run once, straight to merge.

A hook, and nothing else

Git runs scripts of yours at certain moments. The one it runs right before it finalizes a commit is the pre-commit hook. That is the hook I want: format whatever I’m about to commit, then let the commit go through with the formatted version.

Plenty of tools wrap this up for you (husky, lint-staged, pre-commit frameworks). I didn’t want another dependency in package.json for fifteen lines of shell. Here is the whole thing, at .githooks/pre-commit:

#!/bin/sh
# Format staged files with prettier before each commit, so the CI format
# check is only a backstop, never the place where formatting gets fixed.
set -e
staged=$(git diff --cached --name-only --diff-filter=ACMR)
[ -z "$staged" ] && exit 0
# --ignore-unknown skips binaries etc.; .prettierignore is honored.
git diff --cached --name-only --diff-filter=ACMR -z |
xargs -0 npx prettier --write --ignore-unknown --log-level warn
git diff --cached --name-only --diff-filter=ACMR -z | xargs -0 git add

Read top to bottom, it asks Git for the staged files, stops if there are none, formats the rest, and re-stages them. Four details in there matter more than they look:

  • --cached --name-only makes Git print the files you have staged, by name, instead of a full diff. Staged means the changes you added with git add, the ones about to go into this commit.
  • --diff-filter=ACMR narrows that list to files you are adding or changing (Added, Copied, Modified, Renamed), so the hook leaves a file you are deleting alone.
  • [ -z "$staged" ] && exit 0 bails out when nothing is staged, which happens on a merge commit or an empty one. Without the guard, prettier runs with no files to chew on. The list also gets fetched twice more rather than reused, because the null-separated form below cannot survive inside a shell variable.
  • -z with xargs -0 separates file names by an invisible zero byte instead of a newline. Without it, a file with a space in its name (My Notes.md) splits into two broken arguments. It works on your machine for a year, then breaks on a teammate’s.

The closing git add re-stages the files after prettier touched them. Drop that line and the hook formats your files, then commits the unformatted versions anyway.

--ignore-unknown tells prettier to skip what it doesn’t understand (images, fonts, binaries), so I can pipe it the whole list without filtering first. It still respects .prettierignore, so files you excluded on purpose stay excluded.

Turning it on for everyone

By default Git keeps hooks in .git/hooks, and it never tracks the .git folder, so a hook you write there stays on your machine and no teammate sees it. Put the hook in a folder you can commit instead, like .githooks, and it does nothing until you point Git at it. You could ask each teammate to run that setup command, but a step that leans on memory gets skipped sooner or later.

Git points at a hooks folder through one setting, core.hooksPath. The move is to set it for the whole team without a manual step. npm runs a script called prepare right after npm install, so I wire the two together:

{
"scripts": {
"prepare": "(svelte-kit sync || echo '') && git config core.hooksPath .githooks"
}
}

The first half of that command is whatever your project already ran in prepare (a SvelteKit codegen step here); the part after && is the new line that points Git at the folder. A fresh clone plus the usual npm install is all it takes now. No onboarding doc, no “did you run the setup script?” The hook is in place.

Two honest caveats

If you stage only part of a file (git add -p to commit some hunks and leave others), the hook formats and re-adds the whole file, which pulls in the hunks you meant to leave out. Most commits never hit this, but if you live in partial staging, the hook will surprise you.

The hook also runs on your machine, not in CI, which is the point. The pipeline installs dependencies with scripts disabled (--ignore-scripts, a sensible default that stops a malicious package from running code during install), so prepare never fires there. CI stays a pure verifier. When the hook does its job, the format check shows green and you forget it exists.

That is the goal: a gate that never has to stop you, because you fixed the problem one commit upstream, before it could fail.

Further reading

When the hook outgrows fifteen lines of shell:

  • Pro Git: Git Hooks walks what each hook is and the moment Git fires it.
  • The githooks reference lists every hook past pre-commit, with the inputs and exit-code rules each one follows.
  • pre-commit is the managed framework for the day fifteen lines of shell stops being enough.