Why should code formatting be enforced by tooling rather than by code review, and how do you introduce an auto-formatter into a large existing codebase without destroying version-control blame history?
answer
- Coordination problem: consistency > the choice
- .editorconfig → formatter → on-save → hook → CI
- Version-pin the formatter
- One pure reformat commit + .git-blame-ignore-revs
- Rebase in-flight branches immediately
basics
~20 sFormatting arguments waste review time and have no effect on behavior, so a formatter should decide and CI should check it. To adopt one on an old codebase, reformat everything in one dedicated commit and tell the blame tool to ignore that commit.
solid answer
~50 sFormatting is a **coordination problem, not a correctness problem**: any consistent choice beats an inconsistent one, so the cheapest resolution is to let a deterministic tool decide and remove the topic from human review entirely. Benefits: zero-noise diffs (no reformat churn hiding real changes), no bikeshedding, no reviewer nitpicks, uniform reading experience. The layered setup is: `.editorconfig` for editor-level basics (indent, charset, final newline, trailing whitespace), an opinionated formatter (Prettier, gofmt, Black, ktlint/Spotless, clang-format, dotnet format) with config committed to the repo, format-on-save plus a pre-commit hook for the fast local loop, and a CI format-check job as the backstop. Adoption on a legacy codebase: land the mass reformat as a **single, isolated commit that changes nothing else**, then record its commit hash in `.git-blame-ignore-revs` (honored by `git blame --ignore-revs-file` and by GitHub's blame view automatically). Merge or rebase in-flight branches immediately, since every open pull request will conflict. Optionally stage per-directory if the repo is huge.
code
text · 7 lines# .git-blame-ignore-revs (repo root)
# Repo-wide auto-format, no behavior change
8a1c2f9e7b3d4c5a6e8f0b1d2c3a4e5f60718293
# Make local blame honor it permanently:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# GitHub's blame view picks the file up automatically.go deeper
Say the formatter decides so nobody argues, and CI checks it; mention format-on-save and that a big reformat should be its own commit.
Lay out the layered stack (.editorconfig, formatter, on-save, hook, CI check) and explain diff noise and review-attention cost as the motivation.
Own the migration mechanics: single pure reformat commit, .git-blame-ignore-revs, rebasing in-flight branches, version-pinning, and the incremental alternatives with their trade-offs.
Frame it as removing a whole class of decisions from human attention, name the explicit boundary between what the tool owns and what review still owns, and weigh org-level rollout (announcement, timing, per-team opt-in, fail-vs-autofix policy).
### Why formatting belongs to a machine Formatting has an unusual property: **the specific choice barely matters, but consistency matters a lot.** Two-space vs four-space indent has no measurable effect on defect rates; a file where both appear does have a cost. That makes it a pure coordination problem — the kind of decision that should be made once, encoded, and never discussed again. The costs of leaving it to humans: - **Bikeshedding.** Formatting debates are maximally accessible (everyone has an opinion, no expertise required) and therefore crowd out substantive design discussion in review. - **Review-attention tax.** Every nitpick comment spends reviewer attention that could have found a real bug, and slows the change. - **Diff noise.** Without a canonical format, editors silently reformat touched regions. A three-line fix arrives as an eighty-line diff, hiding the actual change and making review skim rather than read. - **Inconsistent enforcement.** Human enforcement is uneven — strict from some reviewers, absent from others — which feels arbitrary and generates resentment. By contrast a formatter is deterministic, instantaneous, and impersonal. "The formatter did it" ends the argument. ### The enforcement stack (layered, fastest feedback first) 1. **`.editorconfig`** — a small, editor-agnostic file at the repo root covering the primitives every editor understands: `indent_style`, `indent_size`, `end_of_line`, `charset`, `insert_final_newline`, `trim_trailing_whitespace`. It catches the basics even for file types no formatter handles. 2. **An opinionated formatter, config committed to the repo.** Examples by ecosystem: gofmt (Go — famously zero-config), Prettier (JS/TS/CSS/Markdown), Black (Python), ktlint / Spotless (Kotlin/Java), rustfmt, clang-format, `dotnet format`. "Opinionated" — few or no options — is a feature: it minimizes the surface for re-litigation. 3. **Format-on-save in the editor**, with settings committed where the ecosystem supports it (editor settings files, IDE code-style files). This makes the correct format the *default* outcome, so nobody experiences the rule as friction. 4. **A pre-commit hook** (via a hook manager or a native hook), ideally formatting only *staged* files so it stays sub-second. Hooks are bypassable and not installed for everyone, so they are convenience, not enforcement. 5. **CI check as the real gate.** Run the formatter in *check* mode (for example `prettier --check`, `gofmt -l`, `black --check`, `ktlintCheck`) and fail the build on drift. Some teams instead have a bot auto-format and push a fixup commit; that avoids failed builds but mutates contributor branches, which can surprise people and breaks on forks with restricted tokens. **Version-pin the formatter.** Different formatter versions produce different output; an unpinned tool means CI and developer machines disagree, or an upgrade silently rewrites thousands of files. Pin it in the lockfile/toolchain and upgrade deliberately, as its own commit. ### Adopting on a large legacy codebase The fear is real: a repo-wide reformat rewrites nearly every line, and blame starts attributing everything to one commit and one person, destroying the archaeology developers rely on when debugging. The standard playbook: 1. **Pick a quiet moment** — minimal open branches, ideally right after a release cut. 2. **Land the reformat as one commit containing nothing else.** No 'while I was in there' fixes. Purity is what makes the next step safe and makes the commit trivially reviewable ('CI is green and the only change is the formatter's output'). 3. **Record the commit hash in `.git-blame-ignore-revs`** at the repo root — one hash per line, comments allowed. Then: - Locally: `git blame --ignore-revs-file .git-blame-ignore-revs`, or set it permanently via `git config blame.ignoreRevsFile .git-blame-ignore-revs`. - GitHub's blame view picks up this file automatically. Blame then skips through the reformat commit to the last commit that made a *substantive* change to each line. 4. **Turn on the CI check in the same change or immediately after**, so the codebase can never drift back. 5. **Rebase or merge every in-flight branch right away.** Every open branch will conflict with a repo-wide reformat. A useful trick: on a conflicted branch, run the formatter on the branch's pre-merge state and *then* merge — the two sides then agree on formatting and most conflicts evaporate. 6. **Communicate before, not after.** Announce the date, explain `.git-blame-ignore-revs`, and share the one-line config developers should set locally. **Incremental alternatives** when a big-bang reformat is politically or practically impossible: - **Format-on-touch**: only format files a change already modifies. Pro: no mass churn. Con: years of mixed state, and the 'did you touch it?' rule needs enforcement of its own. - **Directory-by-directory**: reformat one module per commit, each ignored in blame. Spreads the pain and lets each team opt in on its own schedule. - **Whitespace-ignoring flags**: `git blame -w` and `git diff -w` ignore whitespace-only changes, which softens (but does not eliminate) the pain for pure-whitespace formatters; formatters that also rewrap lines change more than whitespace, so `-w` is not a full substitute for the ignore-revs file. ### Where the boundary sits A formatter owns whitespace, wrapping, brace placement, and quote style. It does **not** own naming, function size, declaration order (the newspaper/stepdown ordering), or semantic grouping via blank lines — those stay with linters (partially) and human review (mostly). Being explicit about that boundary keeps reviewers from feeling that 'the tool handles style' means 'style is no longer reviewed'.
- Should CI fail the build on formatting drift, or auto-format and push a fixup commit?Failing is the simpler, more predictable default: the contributor keeps ownership of their branch and the fix is one local command. Auto-push bots avoid red builds but mutate someone else's branch (surprising, and it can race with local work), and they typically cannot push to forks without elevated tokens. Many teams do both: fail in CI, and offer a one-command local fix.
- Why pin the formatter's version?Formatter output changes between versions. Unpinned, developer machines and CI can disagree (endless 'works for me' drift), and an unnoticed upgrade rewrites thousands of files inside an unrelated change. Pin it in the lockfile/toolchain and treat upgrades as their own dedicated, blame-ignored commit.
- What does a formatter NOT solve, so still needs human review?Naming, function and class size, declaration ordering (newspaper/stepdown), blank-line grouping that encodes semantic thoughts, and comment quality. Formatters normalize mechanical layout; the judgment-based parts of readability remain review work.
Like agreeing which side of the road to drive on: the specific side is arbitrary, universal agreement is everything, and you enforce it with painted lines rather than by arguing with each driver.