Finding who changed a line with log and blame Jump to heading

Plain git blame answers a question nobody asked: who touched this line most recently. On a file that has been reformatted, moved, renamed or swept by a tool, that is a formatter, a migration script, or whoever reindented the block — and the change you are actually looking for is several commits behind it. Getting to the real provenance takes three or four extra options and a different command for the harder cases. This recipe covers them, within bisect and history forensics.

When to use this approach Jump to heading

  • Blame attributes a line to a formatting or renaming commit.
  • A file has been moved and its history appears to start at the move.
  • You need to know when a specific value or string entered the codebase.
  • A function’s behaviour changed and you want the sequence of changes to it.
  • If the file is young and has never been swept, plain blame is fine.

Step 1 — Ignore the commits that are not answers Jump to heading

Two options remove most of the noise, and the second is worth configuring permanently.

# Ignore whitespace-only changes when attributing lines
git blame -w -- src/payments/refund.ts | head -5
# Ignore specific commits entirely — sweeps, renormalisations, mass renames
cat .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
git blame -- src/payments/refund.ts | head -5
# Verification: the sweep commit no longer appears in the output
git blame -- src/payments/refund.ts | grep -c "$(head -1 .git-blame-ignore-revs | cut -c1-8)"
Plain blame against configured blameWithout options, a file that was reformatted last quarter attributes every line to the sweep. With whitespace ignored and the sweep in an ignore list, the same command attributes each line to the change that actually wrote it.git blameblame -w + ignore-revslines attributed to a sweepmost of the filenoneuseful for investigationnoyesworks in the web viewnoyes, with the filesetupnoneone file, one settingthe ignore file is committed, so the improvement applies to everyone

Step 2 — Follow the line through moves and renames Jump to heading

Blame stops at a rename unless told to look further.

# Detect lines moved within the file (-M) and copied from other files (-C)
git blame -w -M -C -C -- src/payments/refund.ts | head -10
# History of the file across renames
git log --follow --oneline -- src/payments/refund.ts | tail -5
# Verification: the history should predate the rename
git log --follow --format='%h %ad %s' --date=short -- src/payments/refund.ts | tail -3

Repeating -C increases how hard Git looks: once searches files modified in the same commit, twice searches every file in the commit, three times searches the whole tree. Each level costs time and finds more.

Step 3 — Search by content rather than by position Jump to heading

When you know what changed but not where, the pickaxe options find the commit directly.

# Commits that changed the NUMBER of occurrences of a string
git log -S'REFUND_WINDOW_DAYS' --oneline --all
# Commits whose diff matches a pattern at all — broader, noisier
git log -G'clampRefund.*\(' --oneline --all | head
# Restrict to a path, and show the diffs
git log -S'90' -p --oneline -- src/payments/refund.ts | head -30
# Verification: the first result should be the introduction
git log -S'REFUND_WINDOW_DAYS' --format='%h %ad %an %s' --date=short --all | tail -1

-S answers “when did this appear or disappear” and -G answers “when was this touched”. Reaching for the second when you want the first is the usual reason a pickaxe search returns forty commits.

Four questions, four optionsBlame answers who wrote a line as it stands. Follow answers what happened to the file before its current name. The pickaxe answers when a value entered the code. Line-range log answers how a specific function evolved.Who wrote this line?blame -w -M -CBefore the rename?log --followWhen did X appear?log -SHow did fn() change?log -L :fn:filemost investigations use two or three of these in sequence

Step 4 — Read a function’s history directly Jump to heading

Line-range log is the least known of these and often the most useful.

# Every commit that touched this function, with diffs, in order
git log -L :clampRefundWindow:src/payments/refund.ts --oneline | head -40
# Or by explicit line range, for code that is not a named function
git log -L 40,72:src/payments/refund.ts --format='%h %ad %an %s' --date=short
# Verification: the output should start at the function's introduction
git log -L :clampRefundWindow:src/payments/refund.ts --format='%h' | tail -1

Git tracks the range as it moves through history, so a function that was reindented, renamed or moved within the file stays followed — which is precisely where blame gives up.

Step 5 — Combine them into an answer Jump to heading

A real investigation chains these rather than using one.

# 1. Which commit introduced the value?
intro=$(git log -S'90' --format='%H' -- src/payments/refund.ts | tail -1)
git show --stat "$intro"
# 2. What did the function look like around then?
git log -L :clampRefundWindow:src/payments/refund.ts --format='%h %s' | tail -5
# 3. Who to ask, and what the change was for
git log -1 --format='%an <%ae>%n%n%B' "$intro"
# Verification: the commit message should explain the value
git log -1 --format='%B' "$intro" | grep -iE 'because|why|refs|ticket'
A provenance investigation, in sequenceBlame gives a starting point, the pickaxe finds where the value entered, line-range log shows how the code around it evolved, and the commit message and author give the reason. Each step narrows what the next one has to search.blame -w -Ma starting commitlog -Swhere it enteredlog -Lhow it evolvedMessage, authorwhywho to askthe last box is the actual answer — the first three are how you reach it

SAFETY WARNING — treat what blame tells you as information about code, not about people. The name attached to a line is whoever last touched it, which may be a formatter, a migration, a rebase that replayed someone else’s commit, or a pairing session recorded under one account. Using blame output to assign responsibility for a defect is both unreliable and corrosive; use it to find context and the person most likely to have it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why does blame show a rebase author rather than the original one? Jump to heading

Because a rebase replays commits with a new committer while preserving the author field, and some views show the committer. Check %an against %cn explicitly when the attribution looks surprising — the author is the person who wrote it, the committer is whoever last replayed it.

Can the ignore-revs file be shared? Jump to heading

Yes, and it should be: commit it to the repository and set blame.ignoreRevsFile in the shared configuration, which is what makes the improvement apply to everyone rather than to whoever configured it. Most forges honour the file in their own blame view as well.

What if the line was introduced by a squash merge? Jump to heading

Blame will attribute it to the squash, which contains many changes. Recovering the original commit is the subject of tracing a regression through a squashed history, and the pull request ref is usually the fastest route.