Searching History: git grep, git log -S/-G/-L, blame -w -C, ignoreRevsFile and "When and Why Did This Line Change?"
Blame's first answer is not enough to find who changed a line, when and why: git log -S finds the commit where a text appeared, -G every change to those lines, -L the history of a single method. We trace Bookstore's institutional discount bug with git grep, the pickaxe, blame -w -C and .git-blame-ignore-revs on Git 2.56.
Version note: The commands in this post were tested with Git 2.56.1; blame.ignoreRevsFile has existed since Git 2.23. GitHub's support for .git-blame-ignore-revs is taken from GitHub Docs as of 15 November 2026.
Guess First
Gül from customer support opened a ticket: Nevin Hanım, the purchasing officer of a school, placed an order for 20 books, but the 10% institutional discount was not applied. In Bookstore, the online bookstore used as the shared example across this site's series, the rule is clear: at least 20 copies for an institutional order. Emre looks at the code and finds the line right away:
$ git grep -n 'quantity >'
src/Bookstore.Ordering/Pricing/DiscountCalculator.cs:7: if (isInstitutional && quantity > InstitutionalMinimumQuantity)
There is a > where it should be >=. Who changed this line, when and why? The first thing that comes to mind is git blame:
$ git blame -L '/quantity >/,+1' src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
90981d9 (Ayse 2026-11-18 10:09:00 +0300 7) if (isInstitutional && quantity > InstitutionalMinimumQuantity)
According to blame, Ayşe. Do you think Ayşe introduced the bug?
$ git log --oneline
de9bde6 Add campaign calculator
90981d9 Apply dotnet format (tabs)
fb07000 Move pricing code to Pricing folder
0daca3f Move constants below ApplyDiscount
ed84177 Round institutional discount to kurus
ef1d6c7 Add order total rounding
156a0af Add institutional discount
90981d9 is a formatting commit: it changed the indentation of the whole file. Blame shows the commit that last touched a line; it does not care whether the change meant anything. In this post we look at the tools for asking the right question of the history: searching today's code, searching for changes, the history of a single method, and a blame that ignores formatting commits.
git grep: Where Is It Today, Where Was It Yesterday?
git grep is the plain grep that knows about Git. It only searches tracked files, so ignored folders such as bin/, obj/ and node_modules/ are never scanned, and because it takes the file list from the index it is fast in big repos. Two features make it really useful. The first is -p, which tells you which method a match is in:
$ git grep -n -p 'RoundToKurus' -- '*.cs'
src/Bookstore.Ordering/OrderTotals.cs=3=public static class OrderTotals
src/Bookstore.Ordering/OrderTotals.cs:5: public static decimal RoundToKurus(decimal amount)
src/Bookstore.Ordering/Pricing/CampaignCalculator.cs=7= public decimal ApplyCampaign(decimal subtotal, bool isCampaignActive)
src/Bookstore.Ordering/Pricing/CampaignCalculator.cs:12: return OrderTotals.RoundToKurus(discounted);
src/Bookstore.Ordering/Pricing/DiscountCalculator.cs=5= public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
src/Bookstore.Ordering/Pricing/DiscountCalculator.cs:10: return OrderTotals.RoundToKurus(discounted);
The lines with = are context: the method signature above the match. Git uses a language-specific "function name" regex to find it, and for C# that depends on one line in the repo's .gitattributes: *.cs diff=csharp. Without it Git uses the default rule, picking the line that starts with a letter in the first column, which in C# often shows the wrong line. The same setting will shortly let git log -L find the method too. In Bookstore's .gitattributes this line has been there since the first commit; besides csharp, the built-in diff= drivers include java, python, rust and html.
The second is that git grep can search a past commit or tag without checking it out:
$ git grep -n 'quantity' v1.0.0 -- '*.cs'
v1.0.0:src/Bookstore.Ordering/DiscountCalculator.cs:8: public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
v1.0.0:src/Bookstore.Ordering/DiscountCalculator.cs:10: if (isInstitutional && quantity >= InstitutionalMinimumQuantity)
$ git grep -n 'quantity >=' $(git rev-list --all --abbrev-commit) -- '*.cs'
ef1d6c7:src/Bookstore.Ordering/DiscountCalculator.cs:10: if (isInstitutional && quantity >= InstitutionalMinimumQuantity)
156a0af:src/Bookstore.Ordering/DiscountCalculator.cs:10: if (isInstitutional && quantity >= InstitutionalMinimumQuantity)
The rule was correct in v1.0.0. The second command searches every commit: the correct form only exists in the first two commits. The bug came after them. This works in a small repo, but because it scans each commit separately it is slow on a long history. The right tool for searching changes is the pickaxe.
The Pickaxe: -S versus -G
$ git log --oneline -S'quantity >='
ed84177 Round institutional discount to kurus
156a0af Add institutional discount
-S<string> lists the commits that change the number of occurrences of the given text. quantity >= was added in the first commit and disappeared in ed84177; that is the commit we are looking for. Now let's look for the constant's name as well and compare with -G:
$ git log --oneline -S'InstitutionalMinimumQuantity'
156a0af Add institutional discount
$ git log --oneline -G'InstitutionalMinimumQuantity'
90981d9 Apply dotnet format (tabs)
0daca3f Move constants below ApplyDiscount
ed84177 Round institutional discount to kurus
156a0af Add institutional discount
-S only found the commit where the constant was defined. In 0daca3f the constant moved below the method, in ed84177 the condition line changed, in 90981d9 the indentation changed; but in none of them did the number of InstitutionalMinimumQuantity occurrences in the file change, so -S does not see them. -G<regex> lists every commit where any added or removed line matches the regex. The rule is simple: -S for "when did this text appear or disappear?", -G for "when did the lines containing this text change?". -S also accepts a regex with --pickaxe-regex, but the counting logic stays the same.
Add -p to -S and -G to see each commit's diff, and --pickaxe-all to also see the changes to the other files in that commit.
The History of a Single Method: git log -L
The pickaxe looks at text. Sometimes the question is "what happened to this method?". git log -L shows the history of a line range or of a method, with diffs limited to that part:
$ git log -L :ApplyDiscount:src/Bookstore.Ordering/Pricing/DiscountCalculator.cs --format='%h %an %s'
90981d9 Ayse Apply dotnet format (tabs)
...
0daca3f Ayse Move constants below ApplyDiscount
...
ed84177 Emre Round institutional discount to kurus
diff --git a/src/Bookstore.Ordering/DiscountCalculator.cs b/src/Bookstore.Ordering/DiscountCalculator.cs
index ffc2f5d..398b31a 100644
--- a/src/Bookstore.Ordering/DiscountCalculator.cs
+++ b/src/Bookstore.Ordering/DiscountCalculator.cs
@@ -8,10 +8,11 @@
public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
{
- if (isInstitutional && quantity >= InstitutionalMinimumQuantity)
+ if (isInstitutional && quantity > InstitutionalMinimumQuantity)
{
- return subtotal - subtotal * InstitutionalRate;
+ var discounted = subtotal - subtotal * InstitutionalRate;
+ return OrderTotals.RoundToKurus(discounted);
}
return subtotal;
}
}
156a0af Emre Add institutional discount
...
The :ApplyDiscount: syntax finds the start and end of the method thanks to diff=csharp in .gitattributes; you can also give line numbers in the form -L 7,14:file. Two things stand out in the output. First, -L followed the file's move into the Pricing/ folder: the diff for ed84177 shows the old path. Second, the change we are looking for is there: while adding the rounding, Emre turned >= into >. Probably not on purpose.
To follow the move across a file's whole history you need --follow; when you give git log a path, by default Git only tracks that path:
$ git log --oneline -- src/Bookstore.Ordering/DiscountCalculator.cs
fb07000 Move pricing code to Pricing folder
0daca3f Move constants below ApplyDiscount
ed84177 Round institutional discount to kurus
156a0af Add institutional discount
$ git log --oneline --follow -- src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
90981d9 Apply dotnet format (tabs)
fb07000 Move pricing code to Pricing folder
0daca3f Move constants below ApplyDiscount
ed84177 Round institutional discount to kurus
156a0af Add institutional discount
--follow works with a single file and guesses renames from content similarity; if most of the file also changed during the move, the tracking can break. That is why it is a good habit to move a file and change its content in separate commits.
Reading blame Properly: -w, -M, -C
Back to the blame from the opening, this time asking it to ignore whitespace changes:
$ git blame -w -L '/quantity >/,+1' src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
ed84177 src/Bookstore.Ordering/DiscountCalculator.cs (Emre 2026-11-18 10:05:00 +0300 7) if (isInstitutional && quantity > InstitutionalMinimumQuantity)
With -w, blame went through the formatting commit, reached the commit that really changed the line, and also printed the file's path at that time. Looking at the whole method makes the difference clearer:
$ git blame -s -L :ApplyDiscount src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
90981d9b src/Bookstore.Ordering/Pricing/DiscountCalculator.cs 5) public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
90981d9b src/Bookstore.Ordering/Pricing/DiscountCalculator.cs 6) {
90981d9b src/Bookstore.Ordering/Pricing/DiscountCalculator.cs 7) if (isInstitutional && quantity > InstitutionalMinimumQuantity)
...
$ git blame -w -s -L :ApplyDiscount src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 5) public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 6) {
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 7) if (isInstitutional && quantity > InstitutionalMinimumQuantity)
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 8) {
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 9) var discounted = subtotal - subtotal * InstitutionalRate;
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 10) return OrderTotals.RoundToKurus(discounted);
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 11) }
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 12)
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 13) return subtotal;
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 14) }
0daca3f0 src/Bookstore.Ordering/DiscountCalculator.cs 15)
0daca3f0 src/Bookstore.Ordering/DiscountCalculator.cs 16) public const int InstitutionalMinimumQuantity = 20;
0daca3f0 src/Bookstore.Ordering/DiscountCalculator.cs 17) public const decimal InstitutionalRate = 0.10m;
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 18) }
-s shortens the output by hiding the author and date; a hash starting with ^ means the first commit of the history. The constants are still attributed to 0daca3f, the commit that moved them. -M looks for lines moved within the same file, and -C looks for lines coming from other files changed in the same commit. Given twice, -C looks at all files in the commit that created the file; given three times, at all commits. The campaign calculation was written by copying the institutional discount:
$ git blame -s src/Bookstore.Ordering/Pricing/CampaignCalculator.cs | sed -n '7,14p'
de9bde6 7) public decimal ApplyCampaign(decimal subtotal, bool isCampaignActive)
de9bde6 8) {
de9bde6 9) if (isCampaignActive)
de9bde6 10) {
de9bde6 11) var discounted = subtotal - subtotal * InstitutionalRate;
de9bde6 12) return OrderTotals.RoundToKurus(discounted);
de9bde6 13) }
de9bde6 14)
$ git blame -s -C -C src/Bookstore.Ordering/Pricing/CampaignCalculator.cs | sed -n '7,14p'
de9bde69 src/Bookstore.Ordering/Pricing/CampaignCalculator.cs 7) public decimal ApplyCampaign(decimal subtotal, bool isCampaignActive)
de9bde69 src/Bookstore.Ordering/Pricing/CampaignCalculator.cs 8) {
de9bde69 src/Bookstore.Ordering/Pricing/CampaignCalculator.cs 9) if (isCampaignActive)
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 10) {
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 11) var discounted = subtotal - subtotal * InstitutionalRate;
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 12) return OrderTotals.RoundToKurus(discounted);
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 13) }
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 14)
-C -C found that the lines came from DiscountCalculator.cs. We took these two outputs with the .git-blame-ignore-revs setting below turned on, which is why copy detection went all the way down to the first commits instead of stopping at the formatting commit. The practical consequence: the bug from ed84177 may have been copied into the campaign calculation too, and what gets fixed in one place can be forgotten in the other.
Skipping Formatting Commits: .git-blame-ignore-revs
Instead of typing -w on every blame, there is a way to skip meaningless commits permanently. This is not only about whitespace; it applies to every commit where a tool such as dotnet format or Prettier rewrote the whole repo:
$ git log -1 --format='%H %s' ':/Apply dotnet format'
90981d9be0b9d1f21c408d153acb677355df0f55 Apply dotnet format (tabs)
$ printf '# dotnet format: tabs\n%s\n' $(git rev-parse ':/Apply dotnet format') > .git-blame-ignore-revs
$ cat .git-blame-ignore-revs
# dotnet format: tabs
90981d9be0b9d1f21c408d153acb677355df0f55
$ git config set blame.ignoreRevsFile .git-blame-ignore-revs
$ git blame -s -L :ApplyDiscount src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 5) public decimal ApplyDiscount(decimal subtotal, int quantity, bool isInstitutional)
^156a0af src/Bookstore.Ordering/DiscountCalculator.cs 6) {
ed84177d src/Bookstore.Ordering/DiscountCalculator.cs 7) if (isInstitutional && quantity > InstitutionalMinimumQuantity)
...
$ git -c blame.markIgnoredLines=true blame -s -L '/var discounted/,+1' src/Bookstore.Ordering/Pricing/DiscountCalculator.cs
?ed84177 src/Bookstore.Ordering/DiscountCalculator.cs 9) var discounted = subtotal - subtotal * InstitutionalRate;
:/<text> finds the newest commit whose message contains that text. The file needs full hashes, one per line; you can and should add # comments, because six months later nobody remembers why a hash is there. The blame.ignoreRevsFile setting goes into the local config, so every developer has to run it once; putting the command in the README helps. A line changed by an ignored commit is attributed to the line Git can match in the previous commit; with blame.markIgnoredLines on, these lines get a ? prefix, and you can mark the lines it cannot match with * using blame.markUnblamableLines.
On the GitHub side no setting is needed. If there is a file named .git-blame-ignore-revs at the root of the repo, GitHub's blame view reads it automatically and shows a notice at the top of the page that revisions are being ignored. That is why the file must have exactly this name and location. Adding the hash of a formatting commit to this file in a separate commit right after it makes mass formatting harmless for the team.
The "Why?" Question, and What Comes Next
We found the answer to "when" and "who". The answer to "why" is in the commit message:
$ git show -s --format='%h %an %ad%n%n%B' --date=short ':/Round institutional'
ed84177 Emre 2026-11-18
Round institutional discount to kurus
Totals were shown with four decimals on the institutional order page.
The message says the purpose of the change was rounding; it says nothing about the condition. So the > was not a deliberate decision but a side effect. The fix is to go back to >= and add a test for exactly 20 copies. In the post on what is inside a commit we talked about why the body of a good commit message matters; this is the moment that body pays off.
Searching is not always this easy. If you have no text to search for and all you can say is "it used to work", the right tool is git bisect: it runs a binary search between a known good and a known bad commit, and with bisect run hooked to a test script it moves on by itself. The details are in the bisect and worktree post, and its place in debugging in the post on narrowing down the problem in the Debugging and Problem Solving series.
Common Pitfalls
The first is assuming -S catches every change. -S looks at the number of occurrences of the text: commits that move a line, re-indent it or change another part of the same line do not show up in the list, as in the InstitutionalMinimumQuantity example. If you want every change to a line, use -G or -L.
The second is trusting the first line of blame. A mass format commit becomes the "last changer" of every line in the file and makes blame meaningless. Prepare .git-blame-ignore-revs before adding a formatter to the repo; make the format commit on its own and do not mix it with other changes.
The third is using -L :Method: without diff=csharp in .gitattributes. Git looks for the start of the method with the default rule and may show a range you do not expect; since the output does not fail, it is hard to notice.
The fourth is forgetting that git grep does not search ignored files. If you are searching a new file that has not been added yet, you need --untracked; for ignored files such as build output, --untracked --no-exclude-standard or plain grep.
A Small Experiment
★☆☆ Pick the name of a constant or a config key in your own repo, search for it with git log -S and git log -G separately, and explain the difference between the two lists.
★★☆ Pick a method in a C# or TypeScript file, add the matching diff= line to .gitattributes, and compare the output of git log -L :MethodName:file with and without that line.
★★★ In a test repo, make a commit that changes the indentation of every file. Look at the same line with plain blame, then with -w, then with .git-blame-ignore-revs. Finally, copy a method into another file and observe the difference between -C, -C -C and -C -C -C.
In this post we read the history. We close the series on the GitHub side: in the next and final post we open pull requests with the GitHub CLI, follow CI from the terminal, reach the GitHub API with gh api and look at extensions.