← back to latest

Find out who actually wrote a line after a bulk reformat

git blame -w --ignore-revs-file .git-blame-ignore-revs src/auth.js

anatomy

git blame
For every line still present in the file, git walks backward through the commits that touched that path and reports the most recent one whose diff added that line. Read the output literally and it says something narrower than people assume: this is not a record of who wrote a line, it is a record of which commit last rewrote it. Those are the same thing right up until someone runs a formatter, because a commit that rewrites a line owns that line whether or not it changed what the line means. A single prettier pass can take authorship of an entire file without altering one byte of behavior, and the two remaining options exist to undo exactly that.
-w
Ignore whitespace when matching a line against its version in the parent commit. A line that moved from two-space to four-space indentation still matches, so blame passes through the formatting commit and keeps going to whoever wrote the logic. This works line by line rather than commit by commit, which is the important limitation: it repairs the indentation half of a reformat and nothing else. The same prettier run that reindented the file probably also swapped single quotes for double quotes and added trailing commas, and every line it touched that way is still stranded. Useful on its own, insufficient alone.
--ignore-revs-file .git-blame-ignore-revs
Read a list of commits and assign blame as though they never happened. For each line an ignored commit touched, git falls through to the previous commit that changed that line, which is the behavior -w cannot give you because it ignores a kind of change rather than a specific commit. The file format is one full hash per line, with blank lines and # comments allowed, the same format git uses for fsck.skipList. The name is a convention rather than a default: git will read any path you point at, but .git-blame-ignore-revs at the repository root is the name GitHub and GitLab look for on their own blame views, so committing it under that name fixes the web interface for everyone at the same time. The option can be repeated.
src/auth.js
Blame annotates exactly one file and requires the path. Unlike git log, there is no mode that surveys the whole tree, because the output is a line-by-line map of a specific file and there is nothing sensible to print without one. A revision can go before the path to blame the file as it stood at that point in history, as in git blame -w v2.1 -- src/auth.js, which answers who owned these lines before the release rather than who owns them now.

Sample output

^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  1) const jwt = require("jsonwebtoken");
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  2) const SECRET = process.env.JWT_SECRET || "dev-secret";
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  3) 
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  4) function verify(token) {
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  5)     const claims = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  6)     if (claims.iss !== "payments-api") {
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  7)         throw new Error("bad issuer");
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000  8)     }
37171b87 (Ada Okafor  2026-07-02 11:03:00 +0000  9)     if (claims.exp < Date.now() / 1000 - 30) {
596a5dee (Marco Reyes 2026-06-11 14:41:00 +0000 10)         throw new Error("token expired");
596a5dee (Marco Reyes 2026-06-11 14:41:00 +0000 11)     }
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000 12)     return claims;
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000 13) }
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000 14) 
^a81cc22 (Ada Okafor  2026-05-04 09:12:00 +0000 15) module.exports = { verify };

Run plain git blame on this file and eleven of those fifteen lines carry Sam Levine and a date in August, which is when the repository was reformatted. Here the reformat is gone and the file reads correctly again: Ada wrote the verifier in May, Marco added the expiry check on lines 10 and 11 in June, and Ada came back in July to allow thirty seconds of clock skew. The ^ prefix on a hash means a boundary commit, in this case the root commit, and it is git telling you it stopped there rather than that the line is especially old.

When you would reach for it

You are reading a line that looks wrong, or dangerous, or deliberate in a way you do not understand, and you want the commit message that explains it. Instead you get a commit called something like “apply prettier to everything” and a person who has no idea why the line exists. The same problem shows up in reverse during review, when you are trying to work out whether a rule has survived three years of deliberate decisions or was introduced by accident, and the answer depends entirely on whether blame is pointing at the author or at the formatter that ran over them. Once the ignore file is committed, this stops being a command you remember to type and becomes the state of the repository.

Gotchas

  • Hashes in the file must be the full forty characters. A short hash produces fatal: invalid object name: 6fba715 and nothing is blamed. Generate entries with git log -1 --format=%H, noting the capital H; the lowercase %h gives you the abbreviated form that does not work here.
  • -w and --ignore-revs-file repair different halves of the same commit, which is why both appear in the command. Whitespace-only reindentation is handled by -w alone, but quote style, trailing commas, and rewrapped long lines are real content changes and need the commit ignored outright. A formatter usually does all of it in one pass.
  • A line attributed through an ignored commit is being credited to a commit whose diff does not contain that exact text, since the text as it stands now was produced by the commit you skipped. Set blame.markIgnoredLines to have those lines marked with a ?, which turns ^a81cc22 into ^?a81cc2. The marker takes a character from the abbreviated hash rather than adding a column, so copying hashes out of marked output gives you a truncated one. blame.markUnblamableLines adds a * for lines git could not attribute to anything earlier.
  • Both options require git 2.23 or later, released in August 2019. That covers the git shipped with macOS command line tools and with Debian 12, but a long-lived build server may be older, and the flags only ever affect display, so an out-of-date machine gives different answers for the same file rather than an obviously broken one.

Variants

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

Set once per repository, after which a plain git blame obeys the file

$ git blame -w -L :verify --ignore-revs-file .git-blame-ignore-revs src/auth.js

Blame a single function by name, with no line numbers to look up first

$ git blame -w --ignore-revs-file .git-blame-ignore-revs --line-porcelain src/auth.js | grep '^author ' | sort | uniq -c | sort -rn

Count surviving lines per author to find out who to ask about this file

lineage

Line-level attribution is older than git and was, for a long time, a tool for assigning blame in the ordinary sense. SCCS could annotate a file in the 1970s and CVS shipped cvs annotate, which is where a generation of programmers learned the habit. Git's own version arrived in 2006, and by most accounts the name was Linus Torvalds being funny about what the feature is really used for. The early implementation drew on work from several people at once: a Perl git annotate from Ryan Anderson came first, and the machinery for tracking lines that moved or were copied between files owes a great deal to Fredrik Kuivinen, with Junio C Hamano doing much of the work that turned it into the built-in command. The flag that makes this entry work is far more recent. Bulk reformatting only became a routine event once formatters like gofmt, black, and prettier made whole-repository rewrites a single command, and the resulting complaint, that one commit had erased the history of an entire codebase, took years to answer. Barret Rhoden contributed --ignore-rev and --ignore-revs-file for Git 2.23 in August 2019. The design decision worth noticing is that the ignore list lives in a committed file rather than in anyone's personal config, which makes a repository's formatting history a shared fact about the project instead of something each developer works around alone.