GitGroundworkGit
13 / 18 written chapters

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.

11 min read4 diagrams12 sections
12On this page
  1. bisect: binary search through history
  2. bisect run: let the machine do it
  3. blame, done properly
  4. .git-blame-ignore-revs
  5. When the line is gone: log -S and log -G
  6. log -L: the history of one function
  7. cherry-pick: copying a commit
  8. range-diff: what changed between two versions of a branch
  9. worktree: two branches checked out at once
  10. git grep
  11. git notes
  12. Submodules and subtrees
Fresher Mid Senior

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.

In one minute

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.

START TEST 1: good TEST 2: bad TEST 3: good TEST 4: bad good bad #8 #12 #10 #11 is the first bad commit
Sixteen commits, four tests. Hollow commits have been ruled out. Each answer removes half of what is left, whatever the answer is.
$ 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 codeBisect reads it as
0Good
1 to 127, except 125Bad
125Can't test this one, skip it
Above 127Stop 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;
FlagWhat it does
-L 41,43 or -L :checkoutOnly these lines, or only this function
-wIgnore whitespace-only changes, so re-indenting doesn't steal the line
-MFollow lines moved within the same file
-CFollow lines moved from other files changed in the same commit
-C -CAlso look in any file that existed in the commit that created this file, which catches "split a module in two"
-C -C -CLook 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.

A B F: the fix (a3f9c1e) D E main R1 R2 F′: new ID, same diff release/2.4 git cherry-pick -x a3f9c1e
D and E are not copied. Only F's own change is replayed on top of R2, which is exactly what a backport needs.
$ 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
shared .git objects, branches, tags config, hooks, stash ~/shop HEAD → feature/cart own index and files ~/shop-hotfix HEAD → hotfix/login .git here is a one-line file
Everything that names history is shared, so a commit made in one folder is instantly visible from the other. Only the "where am I" state is per folder: HEAD, the index, the files, and refs such as 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.

SUBMODULE your repo's tree src/ .gitmodules vendor/lib → 3fa9c1e (one pointer, mode 160000) lib repo its own clone clone needs --recurse-submodules bump = commit a new pointer SUBTREE your repo's tree src/ vendor/lib/index.ts vendor/lib/parse.ts ordinary files, in your history a plain clone just works bump = git subtree pull, a merge into your repo
A submodule stores a commit ID of another repository. A subtree stores that repository's files.
$ 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
SubmoduleSubtree
What your repo storesA commit ID (a "gitlink") and a URL in .gitmodulesThe files themselves
Fresh cloneEmpty folder unless you pass --recurse-submodulesComplete
PinningExact: every commit of yours names one commit of theirsWhatever was last pulled in
Changing the inner codeCommit inside the submodule, push it, then commit the new pointer outside. Two pushes, in that orderEdit in place. Sending changes back upstream with subtree push is slow and fiddly
Common failuresStale checkout after pull; detached HEAD inside; a pointer to a commit nobody pushedBigger history; upstream sync forgotten; git subtree is a contrib script, not core Git
Good fitLarge, independently released code you must pin exactly, such as firmware or a vendored SDKSmall code you rarely update and want to be always present
The honest recommendation

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.