Field Notes

Reformat everything, keep git blame honest: .git-blame-ignore-revs

Reformat everything, keep git blame honest: .git-blame-ignore-revs

This morning I noticed the Scala backend of this site had never been through a formatter. The Spring-to-Play migration produced working code, but some of it was written as one-liners: one line over 800 characters wide, a seven-field case class on a single line. So I added scalafmt, ran it once over the whole backend, and shipped the result as one commit.

That commit touched 80 files, with 5,924 lines added and 2,571 removed. It changed no behaviour, and it would have taken over git blame for most of the backend.

GitHub commit page for d6c2d54, Format the backend with scalafmt, showing 80 files changed, 5924 additions and 2571 deletions
One mechanical commit, 80 files. Without help, blame would point at it for most of the backend.

What a reformat does to blame

git blame answers "which commit last changed this line?" A formatter changes almost every line, so after the reformat the honest answer is nearly always "the reformat". That is true, and it is useless when you want to know why a line exists.

I measured it on one file. VisitService.scala has 260 lines after formatting. A plain git blame attributed 207 of them to the scalafmt commit. The decisions behind those lines were made weeks earlier, in the migration and in a later move from the Cake pattern to Guice. None of that was visible any more.

The fix is a list of commits

Git has supported ignoring revisions in blame since 2.23. You can pass a commit with --ignore-rev, or keep a list in a file and pass that with --ignore-revs-file. By convention the file is called .git-blame-ignore-revs and lives at the repository root:

# Formatting-only commits; GitHub blame skips these. Locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
# Format the backend with scalafmt
d6c2d54617fdb4c774a3b1bf71767f5524bc3b25

Use the full 40-character SHA, one per line. Comment lines are allowed, so write down what each commit was. Someone will ask later.

With the file in place, the same blame on VisitService.scala attributes only 22 lines to the reformat. The other 185 go back to the commits that actually wrote them.

Two git blame excerpts of the RecruiterVisit case class: the default blames commit d6c2d546 on every line, while --ignore-revs-file blames 3277722 with a question-mark marker
Same lines, two answers. With the ignore file, blame goes back to the migration commit that defined the case class.

Lines that move are still traced

I expected the ignore list to work only for lines that kept their shape. It handles more than that. scalafmt split RecruiterVisit from three long lines into one parameter per line, and git still maps those new lines back to the migration commit. When git ignores a commit, it looks for a matching line in the parent version of the file and continues blaming from there.

Two settings make this visible locally:

git config blame.markIgnoredLines true
git config blame.markUnblamableLines true

With them, a line that blame re-attributed past an ignored commit gets a ? prefix. A line that could not be traced gets a *. When I checked the 22 lines still blamed on the reformat, they were all * lines: blank lines and lone closing parentheses that scalafmt added. No earlier commit contains them, so the reformat is the correct answer for those.

GitHub reads the file automatically

I didn't configure anything on GitHub. A .git-blame-ignore-revs at the root of the repository is enough. The blame view then shows a banner saying it is ignoring the revisions in that file:

GitHub blame view of VisitService.scala with the banner Ignoring revisions in .git-blame-ignore-revs; most lines are attributed to the 28-day-old migration commit
GitHub picks up the file on its own. Line 13, a blank line, is the only one in view blamed on the reformat.

Further down the same file, the case classes scalafmt rewrapped are still blamed on the migration from 28 days ago. Only the blank separator lines between them are blamed on the reformat:

GitHub blame showing AnonymousVisit, now spread across nine lines, attributed to the migration commit, while blank lines 40 and 50 are attributed to the scalafmt commit
Nine lines that used to be one, still blamed on the commit that wrote them.

Local git does not read the file by default. Each clone needs it set once:

git config blame.ignoreRevsFile .git-blame-ignore-revs

Three things to get right

Keep the reformat in its own commit

The ignore list works per commit. If the reformat also contains a real change, ignoring it hides that change from blame too. My PR had three commits: the tooling (config, sbt plugin, CI check), the reformat, and the ignore file. One line in the reformat commit was not formatting: it restored an import anorm.postgresql._ that an IDE "optimize imports" pass had dropped. I described it in the commit message so that ignoring the commit doesn't hide it.

The SHA has to survive the merge

The ignore file can only name the reformat after it has been committed, so it goes in a follow-up commit. That only works if the SHA reaches master unchanged. This repository uses merge commits, so it does. A squash merge or a rebase merge would create new commits with different SHAs, and the file would point at a commit that isn't on the main branch. With squash merging, add the SHA after the merge, in a separate small PR.

Make it the last reformat

An ignore list should stay short. CI now fails if a file isn't formatted, and the editor hook formats each file as it is changed, so there shouldn't be a second commit like this one. If there is, it goes on the next line of the file.

Summary

A formatting commit is easy to justify and leaves a lasting cost in blame. Listing its SHA in .git-blame-ignore-revs removes most of that cost: 207 of 260 lines attributed to the reformat became 22, and those 22 are lines the formatter actually created.

Keep reading