G13 · Part 3 · Rescue
Detective tools
Find the commit that broke it, the reason a line exists, and the history of one function, then move fixes between branches.
12On this page
- bisect: binary search through history
- bisect run: let the machine do it
- blame, done properly
- .git-blame-ignore-revs
- When the line is gone: log -S and log -G
- log -L: the history of one function
- cherry-pick: copying a commit
- range-diff: what changed between two versions of a branch
- worktree: two branches checked out at once
- git grep
- git notes
- Submodules and subtrees
Git stores every version of every file, which makes it a very good investigation tool if you know the questions to ask it. This section covers the commands you reach for when something is wrong and you need to know when, who and why, and the ones for moving work around once you do.
Three questions come up on every bug. Why is this line
here? git blame file shows the commit that last
changed each line; open that commit with git show
and read its message. When did it break?
git bisect checks out commits between a good and a
bad version and asks you "good or bad?" until it names the one
that broke it. Can I get that fix onto this branch?
git cherry-pick <commit> copies it over. The
rest of this page is the detail behind those three.
bisect: binary search through history
A bug exists today and did not exist at the last release, and there are 400 commits in between. Reading them is hopeless. Bisect asks you to test the commit halfway between a known good and a known bad one, and throws away the half that your answer rules out. Each test halves the range, so 400 commits take about nine tests (29 = 512), and 10,000 take about fourteen.
$ git bisect start
$ git bisect bad
$ git bisect good v2.3.0
Bisecting: 199 revisions left to test after this (roughly 8 steps)
[5c2e9a1f3d7b0e4c8a6f2d9b1e5c7a3f0d8b4e6c] refactor: move tax helpers into pricing module
$ npm test -- cart
$ git bisect good
Bisecting: 99 revisions left to test after this (roughly 7 steps)
...
8f2c9d1e4b7a0c3f5e6d9b2a1c4f7e0d3b6a9c2e is the first bad commit
commit 8f2c9d1e4b7a0c3f5e6d9b2a1c4f7e0d3b6a9c2e
Author: Marco Ruiz <marco@example.com>
Date: Tue Sep 16 11:20:43 2026 +0100
feat: coupon stacking
$ git bisect reset
git bisect start BAD GOOD does the first three lines in
one. If a commit can't be tested (it doesn't build, or the test needs
something added later), answer git bisect skip and Git picks
a neighbour. git bisect log prints your answers so far, and
git bisect replay reruns them after a mistake. When you are
hunting a change that is not a bug, such as when something got faster,
rename the terms: git bisect start --term-old=slow
--term-new=fast. On a branch full of merges,
--first-parent tests only main's own commits and tells you
which merge brought the problem in.
bisect run: let the machine do it
If a script can tell good from bad, bisect can run the whole search unattended. The exit code is the answer.
| Exit code | Bisect reads it as |
|---|---|
0 | Good |
1 to 127, except 125 | Bad |
125 | Can't test this one, skip it |
Above 127 | Stop the bisect: something is broken in the script itself |
$ cat /tmp/check.sh
#!/bin/sh
npm ci --silent || exit 125
cp /tmp/tax-regression.test.ts tests/ || exit 125
npx vitest run tests/tax-regression.test.ts
$ chmod +x /tmp/check.sh
$ git bisect start HEAD v2.3.0
$ git bisect run /tmp/check.sh
...
8f2c9d1e4b7a0c3f5e6d9b2a1c4f7e0d3b6a9c2e is the first bad commit
bisect found first bad commit
Two details make this work. The script lives outside the repository, because bisect checks out old commits and would otherwise change it under you. And the test that proves the bug is copied in each time, because it didn't exist in the old commits. A failed install exits 125, so a commit that won't build is skipped instead of being blamed.
blame, done properly
git blame shows, for each line, the last commit that changed
it. The point is not to find someone to blame. It is to find the commit,
read its message and its pull request, and learn why an odd line
exists before you delete it. Plain blame is easily fooled, though. A
reformat, or a function moved to another file, makes the mover look like
the author. The flags fix that.
$ git blame -w -C -C -L 41,43 src/cart.ts
e4f9a01b src/cart.ts (Priya Shah 2026-03-04 14:02:11 +0000 41) const subtotal = sumLines(lines);
7d21e0c3 src/pricing.ts (Marco Ruiz 2025-11-19 09:40:52 +0000 42) const tax = roundHalfEven(subtotal * rate);
7d21e0c3 src/pricing.ts (Marco Ruiz 2025-11-19 09:40:52 +0000 43) return subtotal + tax;
| Flag | What it does |
|---|---|
-L 41,43 or -L :checkout | Only these lines, or only this function |
-w | Ignore whitespace-only changes, so re-indenting doesn't steal the line |
-M | Follow lines moved within the same file |
-C | Follow lines moved from other files changed in the same commit |
-C -C | Also look in any file that existed in the commit that created this file, which catches "split a module in two" |
-C -C -C | Look in every file in every commit. Thorough and slow on big repos |
--ignore-rev <sha> | Pretend this commit didn't happen and blame what was there before it |
In the output above, the file name column appears because
-C traced two of the lines back to
src/pricing.ts, where they were first written. Without it,
all three lines would point at the commit that moved them.
.git-blame-ignore-revs
When a team runs Prettier over the whole codebase, that one commit becomes the "author" of nearly every line. List such commits, one full ID per line, in a file at the root of the repository, and point blame at it.
$ cat .git-blame-ignore-revs
3b7e1a4c9d2f5e8a0b1c4d7e0f3a6b9c2d5e8f1a
c04b8a2e6f9d1c3b5a7e9f0d2c4b6a8e0f1d3c5b
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub's blame view reads .git-blame-ignore-revs from the
root automatically. Locally, each developer has to set the config once,
so put the command in your setup script. Add the reformat commit to the
file in a follow-up commit, once its final ID is known (after the PR is
merged, if you squash).
When the line is gone: log -S and log -G
Blame only sees lines that still exist. To find when something was removed, or when a string first appeared, use the pickaxe.
$ git log -S "applyLegacyDiscount" --oneline
a07f3b5 refactor: drop legacy discount path
1c9e4d2 feat: legacy discount for enterprise plans
$ git log -G "rate\s*\*" --oneline -- src/pricing.ts
-S lists commits where the number of occurrences of the
string changed, so it finds additions and deletions but not edits that
leave the count the same. -G takes a regular expression and
lists every commit whose diff has an added or removed line matching it.
log -L: the history of one function
git log -L follows a range of lines, or a whole function,
back through history, showing only the part of each commit's diff that
touched it, and following it as the lines move around the file.
$ git log -L :applyCoupon:src/cart.ts --oneline
8f2c9d1 feat: coupon stacking
diff --git a/src/cart.ts b/src/cart.ts
--- a/src/cart.ts
+++ b/src/cart.ts
@@ -58,7 +58,9 @@
export function applyCoupon(cart: Cart, code: string) {
- const coupon = findCoupon(code);
+ const coupons = [...cart.coupons, findCoupon(code)];
...
$ git log -L 40,60:src/cart.ts
Git finds the function with a built-in pattern that guesses what a
function header looks like. For many languages it guesses better if you
tell it the language in .gitattributes, for example
*.py diff=python or *.rs diff=rust. The same
setting improves the @@ hunk headers in every diff.
cherry-pick: copying a commit
Cherry-pick takes the diff one commit introduced and applies it on top of your current branch as a new commit. The classic use is backporting a fix from main onto a release branch. Because the new commit has a different parent, it has a different ID, and the same change now exists twice in the graph.
$ git switch release/2.4
$ git cherry-pick -x a3f9c1e
$ git cherry-pick -x a3f9c1e^..c04b8a2
$ git cherry-pick main~3..main
$ git cherry-pick -n a3f9c1e
$ git cherry-pick -m 1 4c1d9e2
-x appends (cherry picked from commit
a3f9c1e...) to the message, with the full ID, so anyone can trace
the copy back to the original. Use it for every backport. Ranges follow
the usual rule: A..B means "after A, up to B", so it
excludes A. Write A^..B to include it.
-n applies the change without committing, so you can
combine several picks into one commit. -m 1 picks a merge
commit, taking its diff against the first parent.
A pick conflicts when the target branch has drifted from the code the fix was written against.
$ git cherry-pick -x a3f9c1e
Auto-merging src/tax.ts
CONFLICT (content): Merge conflict in src/tax.ts
error: could not apply a3f9c1e... fix: round tax per line
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
$ git add src/tax.ts
$ git cherry-pick --continue
--skip drops the current commit from a range and moves on,
and --abort puts everything back. To see which fixes on main
have not reached a release branch yet, ask git cherry -v
release/2.4 main. It compares patches, not IDs, so a
cherry-picked copy counts as present: lines starting with
- are already there, lines with + are missing.
range-diff: what changed between two versions of a branch
After a rebase, every commit on your branch has a new ID and the normal
diff against main shows the whole feature again. Reviewers want to know
what changed since last time. range-diff matches
the old commits to the new ones and diffs each pair.
$ git range-diff main feature/cart@{1} feature/cart
1: a1b2c3d = 1: e4f5a6b feat: add coupon model
2: b2c3d4e ! 2: f6a7b8c feat: apply coupon at checkout
@@ src/cart.ts: export function checkout(cart: Cart) {
-+ const total = subtotal - discount;
++ const total = Math.max(0, subtotal - discount);
3: c3d4e5f < -: ------- wip: debug logging
-: ------- > 3: 9d8e7f6 test: cover negative totals
= means unchanged, ! means changed (with a
diff of the diffs), < means dropped and >
means new. feature/cart@{1} is the branch's own reflog, so
"where it was before the rebase". Before a force-push, git
range-diff main @{u} @ compares what you pushed last time with
what you are about to push. Paste it into the PR so reviewers can see
exactly what moved.
worktree: two branches checked out at once
You are deep in a feature and an urgent bug comes in. Instead of stashing and switching, check out a second branch into a separate folder. Both folders share one object database, so it costs almost no disk and no network, and each has its own working tree, index and HEAD.
$ git worktree add -b hotfix/login ../shop-hotfix origin/main
Preparing worktree (new branch 'hotfix/login')
HEAD is now at 5e6f7a8 chore: bump deps
$ git worktree list
/Users/priya/shop 8f2c9d1 [feature/cart]
/Users/priya/shop-hotfix 5e6f7a8 [hotfix/login]
$ git worktree remove ../shop-hotfix
$ git worktree prune
bisect that belong to one checkout.
A branch can be checked out in only one worktree at a time, which stops
two folders from fighting over the same branch. Dependencies and build
output are per folder, so expect to run npm ci in the new
one. A second worktree is also how people run a long test suite on one branch
while coding on another, or run several coding agents side by side
without them overwriting each other's files.
Newer versions filled in the gaps. git worktree add --orphan
-b docs ../site (Git 2.42) starts a branch with no history,
handy for a gh-pages style branch.
--relative-paths, or worktree.useRelativePaths
true (Git 2.48), links worktrees with relative paths so the
whole set survives being moved or mounted into a container.
git worktree lock stops prune from
removing a worktree on a drive that is not mounted, and
git worktree repair fixes the links after you move
folders by hand.
git grep
git grep searches tracked files only, so it skips
node_modules and build output without being told, and it
runs in parallel. It can also search any commit without checking it out.
$ git grep -n -p "applyCoupon" -- "*.ts"
src/cart.ts=58=export function applyCoupon(cart: Cart, code: string) {
src/cart.ts:61: return applyCoupon(next, rest);
src/checkout.ts:12:import { applyCoupon } from "./cart";
$ git grep -n "FEATURE_COUPONS" v2.3.0 -- src/
$ git grep -e "TODO" --and -e "tax" -- src/
-p adds the enclosing function line, marked with
= instead of :. Passing a tag or commit, like
v2.3.0, searches that snapshot.
git notes
Notes attach text to a commit without changing it, so its ID stays the same. They suit facts learned after the commit was made: which build deployed it, a benchmark result, a link to the incident it caused.
$ git notes add -m "Deployed to production 2026-09-20, build 4127" 8f2c9d1
$ git log -1 8f2c9d1
commit 8f2c9d1e4b7a0c3f5e6d9b2a1c4f7e0d3b6a9c2e
Author: Marco Ruiz <marco@example.com>
Date: Tue Sep 16 11:20:43 2026 +0100
feat: coupon stacking
Notes:
Deployed to production 2026-09-20, build 4127
$ git push origin refs/notes/commits
$ git fetch origin "refs/notes/*:refs/notes/*"
Notes live on their own ref, refs/notes/commits, which is
not pushed or fetched unless you ask, as above. GitHub does not display
them. And a rebase leaves them behind on the old commits unless you set
notes.rewriteRef. They are a niche tool, mostly used by CI
systems and by projects that email patches.
Submodules and subtrees
Both put one repository inside another. They make opposite trade-offs.
$ git submodule add https://github.com/acme/money.git vendor/money
$ git clone --recurse-submodules https://github.com/acme/shop.git
$ git submodule update --init --recursive
$ git submodule update --remote vendor/money
$ git config submodule.recurse true
$ git subtree add --prefix=vendor/money https://github.com/acme/money.git main --squash
$ git subtree pull --prefix=vendor/money https://github.com/acme/money.git main --squash
$ git subtree push --prefix=vendor/money https://github.com/acme/money.git fix/rounding
| Submodule | Subtree | |
|---|---|---|
| What your repo stores | A commit ID (a "gitlink") and a URL in .gitmodules | The files themselves |
| Fresh clone | Empty folder unless you pass --recurse-submodules | Complete |
| Pinning | Exact: every commit of yours names one commit of theirs | Whatever was last pulled in |
| Changing the inner code | Commit inside the submodule, push it, then commit the new pointer outside. Two pushes, in that order | Edit in place. Sending changes back upstream with subtree push is slow and fiddly |
| Common failures | Stale checkout after pull; detached HEAD inside; a pointer to a commit nobody pushed | Bigger history; upstream sync forgotten; git subtree is a contrib script, not core Git |
| Good fit | Large, independently released code you must pin exactly, such as firmware or a vendored SDK | Small code you rarely update and want to be always present |
If the code can be published as a package (npm, PyPI, Maven, a Go module), do that and let the package manager pin the version. It gives you exact pinning without either tool's sharp edges. If the two projects always change together, they probably belong in one repository. Submodules and subtrees are for the cases in between.
Finished reading?
0 of 18 chapters read in this series