“It worked last month” is a version control question before it is a debugging question. Git can turn a month of history into one commit — usually in under five minutes — and the commands that do it are the least-used ones in the tool.
Topic 1: Blame, and Why It Usually Lies
git blame src/api.go # who last touched each line
git blame -L 40,60 src/api.go # only lines 40–60
git blame -w src/api.go # ignore whitespace changes
git blame -C -C src/api.go # detect lines moved from other files
git blame 9f8e7d~ -- src/api.go # blame as of before a commit
The naive result is misleading, because the last person to touch a line is very often whoever ran the formatter or moved the file. Two flags fix most of that: -w ignores whitespace-only changes, and -C follows lines that were moved or copied, including from other files when repeated.
The permanent fix for formatting commits is an ignore-revs file:
echo "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c # prettier across the repo" \
>> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
Commit that file. GitHub and GitLab honour it too, so a mass reformat stops destroying blame for everyone rather than just for you.
Blame answers “who”, which is rarely the useful question. The useful question is “why”, and the answer is in the commit message and the pull request — which is why the commit-craft lesson matters here rather than as a style preference. git log -1 <sha> after a blame is the actual move.
Topic 2: Searching History for Content
git log can search the changes rather than the messages, and this is the most underused capability in Git.
git log -S "getUserToken" --oneline # commits that ADD or REMOVE that string
git log -S "getUserToken" -p # ...with the diffs
git log -G "token.*expiry" --oneline # regex over the diff text
git log --oneline -- src/auth/ # commits touching a path
git log --follow -- src/auth/token.go # ...across renames
git log --grep="token" --oneline # search commit MESSAGES
git log --author="alice" --since="3 months ago"
-S (the “pickaxe”) is the one to remember. It finds commits where the count of a string changed — the commit that introduced a function, or the commit that deleted it. “When did this configuration key appear?” and “who removed this check?” are both one command.
-G matches the diff text with a regex, so it also finds commits where a line containing the pattern was modified, not only added or removed.
Two more, for the awkward cases:
git log --all --source -- path/that/no/longer/exists # find a deleted file's history
git log --diff-filter=D --oneline -- path/to/file # the commit that deleted it
The second one answers “who deleted this file and why” in a single command, and it is otherwise surprisingly hard to determine.
Topic 3: Bisect
When you know it worked at some point and does not now, but not why:
git bisect start
git bisect bad # HEAD is broken
git bisect good v1.4.0 # this tag was fine
# Git checks out a commit halfway between
# ...test it...
git bisect good # or: git bisect bad
# ...repeat...
# 9f8e7d is the first bad commit
git bisect reset # back to where you started
log₂(n) tests: 1,000 commits ≈ 10 tests, 10,000 ≈ 14.
Automate it. This is the version that makes bisect genuinely powerful:
git bisect start HEAD v1.4.0
git bisect run ./check.sh
#!/bin/bash
# check.sh — exit 0 = good, 1 = bad, 125 = skip (cannot test this commit)
make build || exit 125 # does not compile → skip, do not blame
./run-repro-case || exit 1
exit 0
Exit code 125 is the one people miss: it tells bisect this commit cannot be judged, so a broken intermediate build does not get blamed for your bug. Walk away and come back to the answer plus the diff.
Bisect works for anything you can test mechanically, not only crashes: a performance regression (exit 1 if a benchmark exceeds a threshold), an output change, a binary that grew by 40%. Any yes/no question about a build is bisectable.
When bisect struggles:
- The bug is intermittent. Make the test loop until it is confident, or bisect will lie.
- Commits do not build. Use
exit 125, and consider whetherrebase -i --exec 'make'should be part of your workflow so this stops happening. - The range contains merges.
--first-parentrestricts bisect to the mainline, which finds the merge that introduced the problem rather than an internal commit of a branch.
Topic 4: Reading a Merge’s Contribution
Merges hide changes from casual inspection, which is why “the code was there before the merge and gone after” is such a common report.
git log --merges --oneline # just the merges
git log --first-parent --oneline # the mainline, skipping branch internals
git show -m <merge-sha> # the diff against EACH parent
git show --first-parent <merge-sha> # against the mainline only
git log --cc <merge-sha> # the combined diff — conflicts resolved here
git show --cc is the important one when investigating a bad merge: it shows only the hunks where the merge resolution differs from both parents — in other words, exactly the lines a human decided during conflict resolution. That is where a merge loses code, and it is a very short list to review.
# Did this change survive the merge?
git log -S "the removed line" --oneline --all
Topic 5: A Repeatable Investigation Order
Given “this used to work”, in the order that costs least first:
1. WHAT changed? git log --oneline -20
git log --oneline v1.4.0..HEAD -- src/auth/
2. WHEN did this line appear or disappear?
git log -S "the string" -p
3. WHO and WHY? git blame -w -C on the suspect line
git log -1 <sha> — read the message
4. Can I test it mechanically?
git bisect run ./check.sh
5. Was it a merge? git log --merges + git show --cc
6. Is it in this release? git tag --contains <sha>
Steps 1–3 take seconds and solve most cases. Step 4 is for when nothing is obvious, and it is the one that scales to a year of history.
Topic 6: Making Your Repository Investigable
Everything above is easier or harder depending on choices made months earlier:
- Small, single-purpose commits. A bisect that lands on a 40-line commit names the bug; one that lands on a 2,000-line “merge feature branch” names nothing.
- Commit messages that say why. During a bisect this is the difference between an answer and another hour of reading.
- Commits that build.
git rebase -i --exec 'make test'before opening a PR keeps every commit bisectable. - A
.git-blame-ignore-revsfile, updated with each mass reformat. --first-parent-friendly history, which means merging feature branches rather than letting individual commits from many branches interleave onmain.
None of these are hygiene for its own sake. Each one is a specific investigation, months from now, that takes five minutes instead of an afternoon.
Try it yourself: pick any bug fixed in a repository you have, find its commit, and then find that same commit three ways — log -S on a string it changed, blame on the line, and bisect with a script. The relative speed of the three on real history is the lesson.
Common mistake: debugging by reading code when the question is “what changed”. If it worked in v1.4.0 and does not now, the answer is in the diff between them, and Git can narrow that to one commit faster than anyone can read the codebase. Start with history; move to code once you know where to look.