Menu for Per-Arne's Git Good!
Courses and project Per-Arne's Git Good!

Bug hunting: log -S, blame and bisect

Module 5: The toolbox · 14 min

The interactive lesson needs JavaScript. The text below is the complete lesson.

After this lesson you can

  • find the commit that introduced a bug with git bisect and a test, and end the search with git bisect reset apply
  • find the commit that added, removed or changed a piece of text with git log -S and git log -G, and read its diff with -p analyse
  • read git blame output to see which commit last changed each line, and explain why that does not prove who caused the bug understand

Understand: History as a crime scene

When something is wrong, you usually ask: where did this text come from, or when did this stop working? Git has tools for both. They all read the history you already have.

With two dashes and a file name, git log shows only commits that changed that file. For config.txt there are just two, D and F. Add -p and you get the diff of each one.

git blame shows, line by line, which commit last changed that line. In config.txt both lines come from F. That is a good place to start, but whoever last touched a line did not necessarily cause the bug.

git log -S with a piece of text finds commits where the number of occurrences of that text changed: where it appeared, disappeared or moved to another file.

Sometimes you do not know which text is wrong, only that it used to work. Then you use git bisect. You name a good and a bad commit, and Git checks out a commit halfway between them.

For each commit Git checks out, you answer good or bad, and the remaining range is cut in half. A thousand take only about ten.

With git bisect run and a test, Git does the work itself. Exit code zero means good, 125 means skip, and any other code from 1 to 127 means bad.

Watch: Bisect: when did it stop working?

Users report that the price is wrong. The test confirms it: 100 should become 125 with VAT, but it becomes 152. At the tag v1.0, it worked.

$ sh test.sh

We start a binary search. Git now needs two facts: a commit that is bad, and one that is good.

$ git bisect start

The commit we are on is bad. We just saw that.

$ git bisect bad

The tag v1.0 is good. Now Git has both ends and jumps straight to a commit halfway between. The bug is in one of the seven commits after the good end. How many must we test?

$ git bisect good v1.0

Instead of testing by hand, we let Git run the test. Git reads the code the test exits with, answers good or bad and carries on. After three tests we have the answer: Tidy config file is the first bad commit.

$ git bisect run sh test.sh

Look at the graph: HEAD is on the last commit Git tested, not on the guilty one. The answer is in the output. git bisect reset ends the search and takes us back to main.

$ git bisect reset

Tasks

  1. Tell Git that the tag v1.0 is good.
  2. Find the first bad commit. Feel free to let Git run the test sh test.sh for you.
  3. End the search and go back to where you started.

Watch: The text trail: log, blame, -S and -G

Now we know the bug came with Tidy config file. Two dashes and the file name limit the log to config.txt, and -p shows the diff of each commit. There it is: 25 was removed and 52 added, together with a new comment. It looks like a typo during a tidy-up.

$ git log -p -- config.txt

git blame shows which commit last changed each line. Both lines come from Tidy config file. Here that is the right commit, but blame only tells you who last touched the line.

$ git blame config.txt

Finally we ask where the correct value has been. -S finds commits where a piece of text appears or disappears. Which commits do we get?

$ git log -S "vat=25" --oneline

-S "vat=" would miss the tidy-up, because the text vat= occurs as many times before as after. -G instead searches for changed lines that match a pattern. That is why Tidy config file is on top here.

$ git log -G "vat=" --oneline

Tasks

  1. Show the changes in each commit that touched config.txt.
  2. Show which commit last changed each line in config.txt.
  3. Find every commit where the text vat=25 appeared or disappeared.

Practise: Transfer: a new bug in a new project

A different project: a small greeting script with the test sh check.sh and a settings file. The test fails on main, but worked at the tag v0.1. No demo this time.

Tasks

  1. Find the first commit where sh check.sh fails. Mark it with the tag first-bad, and end the search so you are back on main.
  2. Someone removed the setting Retry: 3 from settings.txt. Find the commit that removed it, and mark it with the tag removed-retry.
  3. Someone changed the value of Timeout in settings.txt, without saying so in the commit message. Find the commit that changed the value, and mark it with the tag timeout-change.

Check yourself: Recall it

git blame shows that a commit by Kari last changed the line where the bug shows. What do you know then?

  • That Kari caused the bug
  • That Kari's commit was the last one to change that exact line
  • That the line has been wrong ever since Kari joined the project

What do you need before git bisect can help you?

  • A known good commit, a known bad commit and a test that gives the same answer every time
  • The name of the file with the bug
  • Only the bad commit; Git finds the good one itself

During git bisect run, the test exits with code 0. What does that mean to Git?

  • The commit is bad, because the test found something
  • The commit is good
  • The commit should be skipped

Review from “Branches and HEAD”: in the middle of a bisect, HEAD is detached on a test commit. You make a commit there with a new test. What do you do before git bisect reset, so you do not lose it?

  • Nothing. git bisect reset takes it along to main
  • Give it a name with git branch
  • Run git commit --amend, so it sticks

Common misconceptions

  • “git blame shows who caused the bug.” It shows which commit last changed each line. The cause can be somewhere else entirely.
  • “Bisect finds the bug by itself.” Bisect needs a good and a bad commit and a test that gives the same answer every time.
  • “After git bisect run, HEAD is on the guilty commit.” HEAD is wherever Git tested last. The answer is in the output, and you end the search with git bisect reset.

Read more

Pick the tool by the question

Question Tool
Which commits touched this file? git log --oneline -- config.txt
What did they change? git log -p -- config.txt
Which commit last changed these lines? git blame -L 2,3 price.sh
When did this text appear or disappear? git log -S "vat=25"
When did a line matching this pattern change? git log -G "vat=[0-9]+"
When did this behaviour break? git bisect

Every answer is a candidate, not a verdict. Read the diff with git show, and confirm the cause with a targeted test before you conclude anything.

Pickaxe: -S counts, -G greps

-S lists commits where the number of occurrences of a string changes. -G lists commits whose diff has an added or removed line matching a regular expression. The difference matters when a value is edited in place. In the lesson's repository the tidy-up changed vat=25 to vat=52. The string vat= occurs once before and once after, so -S does not see it, while -G does:

$ git log -S 'vat=' --oneline
e3ef077 Move VAT rate to config file
e94df88 Add price calculator
$ git log -G 'vat=[0-9]+' --oneline
83bed6a Tidy config file
e3ef077 Move VAT rate to config file
e94df88 Add price calculator

Both accept a path after -- to narrow the search, and -p to show the diffs. Add --follow to git log for a single file that has been renamed. To follow a range of lines through history, use git log -L 2,3:price.sh.

Blame without being fooled

git blame shows, for every line, the commit that last changed it. Last changed is not the same as caused. Re-indenting, moving or reformatting a line makes the reformatter the "author". Options help:

  • -w ignores whitespace changes.
  • -M detects lines moved within the file, -C lines moved or copied from other files changed in the same commit. Git needs enough characters to recognise a move, so very short lines are not detected.
  • -L 10,20 limits the output to some lines. -s hides author and date.
  • --ignore-rev or a file listed with --ignore-revs-file skips known formatting commits.
$ git blame sum.sh
^db3ccc3 (Ada 2026-09-01 10:00:00 +0200 1) total() {
7e088013 (Bob 2026-10-01 10:00:00 +0200 2)     echo $(($1 + $2))
^db3ccc3 (Ada 2026-09-01 10:00:00 +0200 3) }
$ git blame -w sum.sh
^db3ccc3 (Ada 2026-09-01 10:00:00 +0200 1) total() {
^db3ccc3 (Ada 2026-09-01 10:00:00 +0200 2)     echo $(($1 + $2))
^db3ccc3 (Ada 2026-09-01 10:00:00 +0200 3) }

Bob only re-indented line 2. A ^ in front of an ID marks the boundary commit, here the first commit. Blame answers "which commit", never "whose fault".

Bisect in depth

Bisect needs three things: a commit you know is bad, a commit you know is good, and a test that measures the same property every time. git bisect start BAD GOOD sets both ends at once. Git checks out a commit roughly in the middle; in a history with merges the candidate need not be the exact midpoint. You answer git bisect good or git bisect bad until Git prints "is the first 'bad' commit". Bisect assumes the range switches from good to bad once. If the bug disappears and comes back, it finds one of those switches, not necessarily the first.

Skip what you cannot test. If a commit does not build, or the test cannot run there, answer git bisect skip instead of guessing. Git then picks a neighbour. If the remaining candidates are all skipped, Git lists them all and stops, and you report that uncertainty rather than pretending to know. A wrong good/bad answer makes bisect confidently point at the wrong commit.

Automate. git bisect run CMD runs a command at each step and reads its exit code: 0 is good, 125 is skip, 1 to 127 except 125 is bad, and anything else aborts the search. A test script can therefore skip commits where the test does not apply:

test -f config.txt || exit 125
sh test.sh

Use your own words. When you are looking for when something changed rather than broke, the words good and bad are confusing. git bisect start --term-old=cheap --term-new=expensive lets you answer git bisect cheap and git bisect expensive. git bisect terms reminds you which words are in use.

Keep a record. git bisect log prints every answer you gave. Save it to a file, fix a wrong answer in an editor, and replay it with git bisect replay:

$ git bisect log > bisect.log
$ git bisect reset
$ git bisect replay bisect.log

Always end with reset. During bisect HEAD is detached on a test commit. git bisect reset takes you back to the branch you started from. Write the answer down first: after a git bisect run, HEAD is wherever Git tested last, not necessarily the guilty commit.

Tests that lie

Bisect is only as good as the test. A test that depends on the network, the clock or machine load can fail for reasons unrelated to the change, and bisect will then name an innocent commit. Run the test twice on the candidate and its parent, read the diff, and if possible write a regression test that would have caught it.

Official documentation