Skip to content
Chapter 96Lesson 2

Reflog, bisect, and the rescue toolkit

Git's rescue and history-rewriting toolkit, reflog, bisect, cherry-pick, revert, and interactive rebase, governed by where a commit lives.

A rebase goes wrong and four hours of work vanish from your branch. A test that was green last week is red today, and one of the forty commits since then is to blame. A one-line fix sits on a feature branch that won’t merge for a week, and production needs it now. These problems show up once you’ve started shipping like a team: more commits, more branches, more history to get lost in.

The previous lesson taught the everyday loop: small branches, pull --rebase, and a squash-merge so main reads like a changelog. This lesson is what you do when a ship goes sideways, and that same squash-merge discipline makes most of these rescues cheaper. You’ll pick up four power tools, plus the smaller commands each leans on: reflog for getting lost work back, bisect for hunting the commit that broke something, cherry-pick and revert for moving and undoing individual commits, and interactive rebase for cleaning up messy history before review. The skill isn’t typing them; it’s knowing which one a problem calls for, and that always comes back to one question.

Where a commit lives decides what you can do to it

Section titled “Where a commit lives decides what you can do to it”

Before reaching for any tool in this lesson, ask: where does this commit live? The three possible answers form a gradient from “do whatever you want” to “look but don’t touch.”

A commit can exist only on your machine. You committed it but haven’t pushed it, so nobody else has a copy. Rewrite its message, fold it into another commit, reorder it, or delete it outright, all safely, because the only history you’re changing is one only you can see.

A commit can be pushed to your feature branch but not yet merged. There’s a copy on the remote now, and a teammate could in principle have pulled it, though on a short-lived branch that’s still yours, almost nobody has. So this zone is mutable with care: you can still rewrite, but then the remote disagrees with your machine and you overwrite it with git push --force-with-lease. The --lease part, from the previous lesson, refuses the push if someone pulled and added a commit in the meantime, so you can’t silently bulldoze their work.

A commit can be merged to main. Others have pulled it, and it’s woven into the shared history the whole team builds on. Rewriting it is off-limits, not because Git stops you, but because it can cost the team a day of untangling. When something on main is wrong, you don’t erase it; you add a new commit that undoes it. That tool is git revert, the one exception in this lesson that touches shared history, and it adds a commit rather than rewriting one.

Freely mutable Local only Only on your machine
Allowed move Rewrite freely
Mutable with care Pushed to my feature branch On the remote, on your branch
Allowed move Rewrite, then --force-with-lease
Immutable Merged to main Everyone has pulled it
Allowed move git revert only — never rewrite
The same commit, three sets of rules. The only question before any rescue: which panel is this commit in?

That gradient rests on one mechanical fact. A commit is a snapshot and a branch is just a movable pointer at one of those snapshots, as the previous lesson showed. The consequence: a commit is not deleted just because no branch points at it anymore. When you reset or rebase, you move pointers; the old snapshots become unreferenced but stay on disk, intact, until Git’s garbage collection (GC) eventually sweeps them away. Git also keeps a journal of where every pointer has been, so even an unreferenced commit is usually one command from coming back.

The interactive Learn Git Branching sandbox makes pointers tangible. Type git commit a couple of times to grow a line of snapshots, then git checkout -b feature to drop a new pointer, then git commit again. The branch labels move while the commit nodes just accumulate: pointers slide, snapshots stay.

Type git commit, then git checkout -b feature, then git commit again. Moving a branch pointer never deletes a commit node, and that’s what makes everything in this lesson recoverable.

Open in a new tab ↗

One more term. HEAD is Git’s name for where you are right now: the commit you currently have checked out, normally reached through your current branch. Almost everything Git does moves HEAD, and the next tool is the log of every place it has ever been.

The reflog: a recovery journal of every HEAD move

Section titled “The reflog: a recovery journal of every HEAD move”

Learn this one before you need it: you reach for the reflog the moment you think “I just lost my work,” the worst time to read docs on a command you’ve never run. Knowing it has your back is also what lets you experiment with rebases and resets, since one wrong move no longer erases an afternoon.

Every time HEAD moves, on every commit, checkout, reset, rebase, and merge, Git appends a line to a private, per-repository journal called the reflog. It records your pointer’s movements, not your project history. Since the snapshots those movements left behind aren’t deleted, the reflog is a list of every state your repository has passed through recently, each reachable by its hash.

Recovery is two steps: read the journal with git reflog to find where you were before the mistake, then jump back to it. Here is the reflog right after a reset --hard discarded two commits, with two ways to recover.

Terminal window
$ git reflog
9f2c1ab HEAD@{0}: reset: moving to HEAD~2
3d8e4c7 HEAD@{1}: commit: Add status filter dropdown
a1b9f02 HEAD@{2}: commit: Wire filter to the query
7c4d2e9 HEAD@{3}: rebase (finish): returning to refs/heads/feat/invoice-status
# Recover by jumping the current branch back:
$ git reset --hard 3d8e4c7
# Or, safer — park the lost work on a new branch first:
$ git branch recover-work 3d8e4c7

Read top-down. HEAD@{0} is where you are now, and the top line is the last thing you did, here the reset --hard that caused the problem.

Terminal window
$ git reflog
9f2c1ab HEAD@{0}: reset: moving to HEAD~2
3d8e4c7 HEAD@{1}: commit: Add status filter dropdown
a1b9f02 HEAD@{2}: commit: Wire filter to the query
7c4d2e9 HEAD@{3}: rebase (finish): returning to refs/heads/feat/invoice-status
# Recover by jumping the current branch back:
$ git reset --hard 3d8e4c7
# Or, safer — park the lost work on a new branch first:
$ git branch recover-work 3d8e4c7

The line just below the mistake is the commit you want back. Each entry names its action and message, so you can spot the state you’re hunting for. Copy that hash.

Terminal window
$ git reflog
9f2c1ab HEAD@{0}: reset: moving to HEAD~2
3d8e4c7 HEAD@{1}: commit: Add status filter dropdown
a1b9f02 HEAD@{2}: commit: Wire filter to the query
7c4d2e9 HEAD@{3}: rebase (finish): returning to refs/heads/feat/invoice-status
# Recover by jumping the current branch back:
$ git reset --hard 3d8e4c7
# Or, safer — park the lost work on a new branch first:
$ git branch recover-work 3d8e4c7

The blunt recovery: move the current branch’s pointer straight back to that commit. Fast, but it discards anything after it, so do this only when you’re sure.

Terminal window
$ git reflog
9f2c1ab HEAD@{0}: reset: moving to HEAD~2
3d8e4c7 HEAD@{1}: commit: Add status filter dropdown
a1b9f02 HEAD@{2}: commit: Wire filter to the query
7c4d2e9 HEAD@{3}: rebase (finish): returning to refs/heads/feat/invoice-status
# Recover by jumping the current branch back:
$ git reset --hard 3d8e4c7
# Or, safer — park the lost work on a new branch first:
$ git branch recover-work 3d8e4c7

The careful recovery, and the better default: create a new branch at the lost commit without moving where you are. The work is parked safely, and you can inspect it before deciding what to keep.

1 / 1

Default to git branch recover-<something> <hash> over git reset --hard <hash>: parking the work on a fresh branch can’t make a bad situation worse, and you can merge or cherry-pick from it once you’ve confirmed it’s what you wanted.

The sequence below makes the mechanism concrete: reset --hard HEAD~2 slides the pointer back so two commits become unreferenced, those commits still sit in the reflog by hash, and a final reset to that hash brings the pointer, and the work, back.

feat/invoice-status HEAD
A7c4d2e9
Ba1b9f02
C3d8e4c7
You're at commit C, on branch feat/invoice-status — B and C hold your latest work.
$ git reset --hard HEAD~2
feat/invoice-status HEAD
A7c4d2e9
Ba1b9f02
C3d8e4c7
The pointers slid back to A. B and C lost their label — but they're still on disk, just dimmed and unreferenced.
feat/invoice-status HEAD
A7c4d2e9
Ba1b9f02
C3d8e4c7
git reflog
7c4d2e9 HEAD@{0} reset: moving to HEAD~2
3d8e4c7 HEAD@{1} commit: C
The reflog still lists C by its hash 3d8e4c7. The snapshot was never deleted — only unreferenced.
$ git reset --hard 3d8e4c7
feat/invoice-status HEAD
A7c4d2e9
Ba1b9f02
C3d8e4c7
Reset to that hash and both pointers return to C. Nothing was ever deleted — the pointer just moved, and the reflog remembered where. Your work is back.

Two limits keep the reflog honest. It is local: it lives in your clone alone, so it can’t recover something only a teammate had, and a fresh clone starts empty. It is also temporary: reachable entries survive roughly 90 days and unreferenced ones about 30 before garbage collection may prune them. It’s a net for recent mistakes, not an archive, so recover lost work now.

The reflog gets work back after you’ve lost it; git stash keeps you from losing it when an interruption forces you to drop what you’re doing. The trigger is specific: you’re mid-change, the working tree is too messy to commit, and something arrives that needs a clean tree, like a hotfix to start. You can’t switch branches with a dirty tree in the way, and you don’t want to freeze half a feature into a commit. Stash saves your uncommitted changes onto a stack and resets the tree to clean; when you return, you pop them back.

Terminal window
$ git stash push -m "wip: invoice status filter"
Saved working directory and index state On feat/invoice-status: wip: invoice status filter
$ git switch main # go deal with the interruption
# ...later, back on the feature branch...
$ git switch feat/invoice-status
$ git stash list
stash@{0}: On feat/invoice-status: wip: invoice status filter
$ git stash pop # restore the changes and drop the stash entry

Always label a stash with -m; an unlabeled one is a guessing game a day later. You already use stash without naming it: the rebase.autoStash=true config from the previous lesson stashes your tree around git pull --rebase and pops it back when the rebase finishes.

Section titled “git bisect finds the commit that broke it by binary search”

Some bugs you can read your way to. This is the bug you can’t: a test that passed weeks ago fails now, with a regression hiding somewhere in the last forty commits and no idea which one. Checking them one by one is the tedious work Git can do for you, by binary search.

You know an old commit was good and the current one is bad, so the break is between them. Instead of testing every commit, test the one in the middle: if it’s good the bug is in the newer half, if it’s bad it’s in the older half. Either way one test eliminates half the suspects. Repeat, and the window halves each time, so a thousand commits collapse to about ten tests, log₂(1000).

known good known bad untested testing now
oldest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
newest
#1 — known good (last week) #16 — HEAD, broken now
14 suspects left
Sixteen commits between a known-good baseline and the broken present. The bug is in one of the grey, untested dots.
known good known bad untested testing now
oldest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
newest
#1 — known good (last week) #16 — HEAD, broken now
14 suspects left
Git checks out the midpoint, #8, and asks: good or bad? You run the test. Say it still passes — #8 is good.
known good known bad untested testing now
oldest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
newest
#1 — known good (last week) #16 — HEAD, broken now
7 suspects left
Good — so the break is newer than #8, and everything up to it clears. Suspects halved. Test the midpoint of what's left, #12.
known good known bad untested testing now
oldest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
newest
#1 — known good (last week) #16 — HEAD, broken now
3 suspects left
Bad — the break is at or before #12, so everything from it on is tainted. Halve again. Three suspects left; test #10.
known good known bad untested testing now
oldest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
newest
#1 — known good (last week) #16 — HEAD, broken now
1 suspect left
#10 is the first bad commit — #9 still passes, #10 fails. Four tests, sixteen commits, one culprit: that's log₂(N), not N. The reason you never bisect by hand.

Here is the manual session. You tell Git the two ends, and it checks out midpoints for you to judge:

Terminal window
$ git bisect start
$ git bisect bad HEAD # the current commit is broken
$ git bisect good a1b9f02 # this old commit was fine
Bisecting: 19 revisions left to test after this (roughly 4 steps)
[<sha>] Wire filter to the query
# Git checked out the midpoint. Run your test, then mark the result:
$ pnpm test
$ git bisect bad # ...or `git bisect good` if it passed
# repeat until Git prints the first bad commit, then:
$ git bisect reset # return to where you started

Marking each step by hand is tedious. Hand the whole search to Git instead: give it a command that succeeds when the code is good and fails when it’s bad, which is what a test command does. Treat this one-liner as the default; fall back to manual marking only for bugs no script can check.

Terminal window
$ git bisect start
$ git bisect bad HEAD
$ git bisect good a1b9f02
$ git bisect run pnpm test

A test command exits 0 on pass and non-zero on fail, the signal bisect needs: at each midpoint it reads the exit code, marks the commit good or bad, and stops on the culprit. One more code matters: exit 125 means the commit can’t be evaluated, one that won’t build, and tells bisect to skip it rather than mis-mark it.

This is where squash-merge pays off. Because you squash-merge, every commit on main is one complete, shipped change with a green, deployable tree. So bisect always lands on a whole pull request, and the test it runs is meaningful: that state truly worked or truly didn’t. A history full of merge commits and raw “wip” commits is the opposite, where an intermediate state is broken for reasons unrelated to the bug, so bisect marks it bad for the wrong reason and chases the wrong change.

Copy a commit with cherry-pick, undo one with revert

Section titled “Copy a commit with cherry-pick, undo one with revert”

These two tools are a matched pair: one applies a commit somewhere new, the other applies a commit’s inverse. The hard part isn’t the syntax; it’s knowing which zone the commit lives in.

git cherry-pick <sha> replays a single commit onto your current branch. The classic trigger in trunk-based work: a small fix sits on a feature branch that won’t be mergeable for another week, but you need it shipped today. So you cherry-pick that one commit onto a fresh branch off main, open a tiny pull request, and ship it while the larger feature keeps developing. Cherry-pick copies: it creates a new commit with the same changes but a different hash, because it has a different parent and place in history.

git revert <sha> handles the opposite case: a bad commit has already shipped to main. It’s in the third zone and everyone has it, so rewriting is off the table. Instead of erasing it, you add a new commit that is its exact inverse: whatever the bad commit added, the revert removes, and whatever it removed, the revert puts back. The rollback is clean and the audit trail survives, since both the original commit and its undo stay in history. The rule: production rolls back with revert; a mistake still on your own branch gets fixed with rebase -i or amend. A later chapter on deployment revisits this: Vercel re-promotes the previous deployment to fix the running app fast, and git revert undoes the code behind it.

So the choice is the zone question again, on one scenario: “I shipped a bad commit.” Read the first sentence of each tab.

Terminal window
$ git rebase -i origin/main
# in the editor, change the bad commit's line from `pick` to `drop`
$ git push --force-with-lease # only if you'd already pushed the branch

The bad commit hasn’t left your feature branch (zone 1 or 2), so you can rewrite it away. Drop it with an interactive rebase, and force-push only if the branch was already on the remote.

Two things to watch with cherry-pick. If you cherry-pick a commit that later gets merged normally too, you’ll have two commits with the same changes but different hashes; that’s usually harmless, but when you want the lineage traceable, git cherry-pick -x <sha> appends a (cherry picked from commit …) line to the new message. And don’t cherry-pick commits from main onto your feature branch to “catch up”: that diverges your history from main’s. To stay current, rebase.

You’re on main with a side branch nearby. Run git cherry-pick <id> on one of side’s commits and a new node appears on main with the same change but a new hash. Cherry-pick copies, it doesn’t move.

Open in a new tab ↗

One term to recognize. Backporting uses cherry-pick to bring a fix from a newer branch onto an older release branch, the one place cherry-pick shows up routinely outside trunk-based work. You’ll rarely need it on a single-main web app, but it’s worth knowing the word when a team that maintains old releases uses it.

Shaping history before it leaves the branch

Section titled “Shaping history before it leaves the branch”

Everything here rewrites commits, so it’s only safe in the first two zones: local, or your own un-merged feature branch. These tools exist for one workflow: you commit messily as you work, with messages like “wip” and “fix the test”, because crafting a perfect commit mid-thought wastes attention. Then, before you push for review, you clean that mess into a sequence of commits that read as a clear story. The messy version was for you; the clean version is for the reviewer.

git commit —amend fixes the most recent commit

Section titled “git commit —amend fixes the most recent commit”

Amend is the smallest history edit: it changes only the latest commit. Say you just committed and realized you forgot to stage a file, or the subject line has a typo. Stage the fix if there is one, then run git commit --amend to reopen the editor and fix the message, or git commit --amend --no-edit to fold in the staged changes while keeping the message. The previous commit is replaced by a corrected one. The moment the fix is two or more commits back, amend is the wrong tool and rebase -i, covered next, is the right one.

Terminal window
$ git add src/components/invoice-status-filter.tsx
$ git commit --amend --no-edit
$ git push --force-with-lease # only needed if the original was already pushed

This is the core history-shaping tool, the one you’ll use most. git rebase -i origin/main (or git rebase -i HEAD~5 for the last five commits) opens an editor with one line per commit, each prefixed with a verb you can change. You don’t run commands; you edit a to-do list, save it, and Git carries it out. The verbs are the whole vocabulary:

  • pick: keep the commit as-is. This is the default on every line.
  • reword: keep the commit, but edit its message.
  • edit: stop after applying this commit so you can amend it or split it, then continue.
  • squash: fold this commit into the one above it, and combine both messages.
  • fixup: fold this commit into the one above it, and discard this one’s message.
  • drop: remove the commit entirely.

You can also reorder commits by moving their lines. One detail trips up nearly everyone the first time: the list reads top-to-bottom as oldest-to-newest, the reverse of git log. Read it that way and the verbs make sense; read it like git log and you’ll squash in the wrong direction.

The to-do buffer below turns five scrappy commits into two clean ones.

Terminal window
pick a1b9f02 Add invoice status filter
fixup 3d8e4c7 wip
reword 7c4d2e9 fix
fixup 9f2c1ab fix typo in label
drop b2e8a14 debug logging, remove later

Top line, so this is the oldest commit and the foundation; pick keeps it untouched. Everything below folds into it or rearranges around it.

Terminal window
pick a1b9f02 Add invoice status filter
fixup 3d8e4c7 wip
reword 7c4d2e9 fix
fixup 9f2c1ab fix typo in label
drop b2e8a14 debug logging, remove later

Two fixup lines collapse the wip and the typo-fix into the commit above them, discarding their throwaway messages. This is how scrappy commits become one clean feature commit.

Terminal window
pick a1b9f02 Add invoice status filter
fixup 3d8e4c7 wip
reword 7c4d2e9 fix
fixup 9f2c1ab fix typo in label
drop b2e8a14 debug logging, remove later

reword keeps this commit’s changes but lets you rewrite its vague fix subject into something a reviewer can read.

Terminal window
pick a1b9f02 Add invoice status filter
fixup 3d8e4c7 wip
reword 7c4d2e9 fix
fixup 9f2c1ab fix typo in label
drop b2e8a14 debug logging, remove later

drop deletes the debug-logging commit, so its changes never reach the rebased branch. Cleaner than a follow-up to remove it.

1 / 1

The verb that does more than its name suggests is edit, which splits an over-large commit. Mark the commit edit, and when the rebase stops on it, run git reset HEAD~ to undo the commit while keeping its changes in your working tree. Then stage and commit those changes in logical pieces, and run git rebase --continue. One bloated commit becomes the two or three focused commits it should have been.

The tabs below show the same branch before and after the rebase above, five noisy commits collapsing into two that tell the story.

  1. drop b2e8a14 debug logging, remove later
  2. fixup 9f2c1ab fix typo in label
  3. reword 7c4d2e9 fix
  4. fixup 3d8e4c7 wip
  5. pick a1b9f02 Add invoice status filter
Five commits as they were written: honest, but noise for a reviewer.

The embed below drops you into a branch with several commits; squash and reorder them and watch the graph linearize in real time.

This is LGB’s Interactive Rebase Intro. Run git rebase -i HEAD~4, then reorder and drop lines in the dialog and hit confirm. Watch the branch’s commits lift onto a new base and the graph go linear. This is the move you’ll make on every feature branch before review.

Open in a new tab ↗

Autosquash turns review fixes into the commit they belong in

Section titled “Autosquash turns review fixes into the commit they belong in”

Interactive rebase almost solves a recurring friction in the review loop. A reviewer points at a line, and the fix belongs inside that original commit, not in a new “address review feedback” commit that’s pure noise. You could rebase -i and manually move a fixup line under the right commit, but Git can wire that up.

When you make the fix, commit it with git commit --fixup=<sha>, naming the commit the fix belongs in. That creates a fixup! <original subject> commit. Later, git rebase -i --autosquash origin/main reads those markers, reorders each fixup! commit beneath its target, and pre-marks it fixup, so the to-do buffer is already correct and you just save. (--squash=<sha> is the same idea when you want to keep the fix’s message instead of discarding it.)

Terminal window
$ git commit --fixup=a1b9f02 # this fix belongs in commit a1b9f02
$ git rebase -i --autosquash origin/main # fixup is pre-sorted and pre-marked; just save
# set it once so plain `git rebase -i` always autosquashes:
$ git config --global rebase.autoSquash true

The previous lesson had you set five global config lines and deliberately left this one out. rebase.autoSquash is the sixth: set it once and plain git rebase -i autosquashes every time. This loop, a fixup commit and a push so the reviewer sees only what changed since their last look, is how pull-request review works in practice, the next lesson’s territory.

Resolving the conflicts these tools create

Section titled “Resolving the conflicts these tools create”

Merge, rebase, cherry-pick, and revert can all stop partway through with a conflict. The situation and the fix are identical whichever tool triggered it, so here it is once.

A conflict happens when Git can’t reconcile two changes to the same lines automatically and needs you to decide. It marks the spot with three lines: <<<<<<<, =======, and >>>>>>>. The first chunk is one side, the second is the other. Edit the file into the result you want, one side, the other, or a hand-merged combination, then delete all three marker lines.

Terminal window
<<<<<<< HEAD
const STATUSES = ['draft', 'sent', 'paid'];
=======
const STATUSES = ['draft', 'sent', 'paid', 'void'];
>>>>>>> feat/invoice-status
# edit the file to the result you want, deleting all three markers, then:
$ git add src/lib/invoice-statuses.ts
$ git rebase --continue

The three markers Git writes into the file. Everything between the first two is one side; everything between the last two is the other. During a rebase, the top side (HEAD) is the commit being replayed onto, which can feel inverted, so read the labels.

Terminal window
<<<<<<< HEAD
const STATUSES = ['draft', 'sent', 'paid'];
=======
const STATUSES = ['draft', 'sent', 'paid', 'void'];
>>>>>>> feat/invoice-status
# edit the file to the result you want, deleting all three markers, then:
$ git add src/lib/invoice-statuses.ts
$ git rebase --continue

The conflict itself: two versions of the same line. Keep one, keep the other, or merge them by hand into a single correct line.

Terminal window
<<<<<<< HEAD
const STATUSES = ['draft', 'sent', 'paid'];
=======
const STATUSES = ['draft', 'sent', 'paid', 'void'];
>>>>>>> feat/invoice-status
# edit the file to the result you want, deleting all three markers, then:
$ git add src/lib/invoice-statuses.ts
$ git rebase --continue

Once the file reads the way you want and the markers are gone, git add it to mark the conflict resolved.

Terminal window
<<<<<<< HEAD
const STATUSES = ['draft', 'sent', 'paid'];
=======
const STATUSES = ['draft', 'sent', 'paid', 'void'];
>>>>>>> feat/invoice-status
# edit the file to the result you want, deleting all three markers, then:
$ git add src/lib/invoice-statuses.ts
$ git rebase --continue

Then continue the operation that stopped. The verb matches the tool: git rebase --continue, git merge --continue, git cherry-pick --continue, or git revert --continue.

1 / 1

If a conflict is more than you want to take on, every one of these operations has a --abort form, such as git rebase --abort, that backs you out to where you started. And if you get past where --abort helps, you know the net: git reflog, then reset.

For a trivial two-line conflict, the terminal is fine. For a messy one, VS Code’s three-way merge editor is faster: it shows theirs, yours, and the result in side-by-side panes.

Here the rerere (“reuse recorded resolution”) config you enabled in the previous lesson pays off: it remembers how you resolved a conflict. On a long-lived branch you rebase onto a moving main repeatedly, you hit the same conflict over and over, and with rerere on, Git replays your earlier resolution instead of making you redo it. The idea underneath is a three-way merge , comparing both sides against their shared ancestor, which is what lets Git replay a resolution with confidence.

Reading history: log, blame, switch, and restore

Section titled “Reading history: log, blame, switch, and restore”

Every tool so far operated on a commit you had to find first: the baseline for bisect, the sha to cherry-pick, the commit a fix belongs in. These read-only commands find those shas, each paired with the question it answers.

Start with git log, which answers most “when did this change?” questions. Here are the invocations worth knowing.

Terminal window
git log --oneline -20 # the last 20 commits, one line each — a quick changelog
git log --graph --oneline --all # the real branch topology, drawn as a graph
git log -p src/lib/invoices.ts # one file's history, with the diff of each change
git log -S "calculateTotal" # the pickaxe: commits that added or removed this string
git log --author="Dana" # one person's commits

The pickaxe (-S) answers “when did this exact string enter or leave the codebase?”, often how you find the commit that introduced a bug or deleted a function, and the fastest way to hand bisect or cherry-pick a target sha.

When the question is “who wrote this line, and why,” reach for git blame <file>: it annotates every line with the commit, author, and date that last touched it. git blame -L 40,80 <file> blames only lines 40 through 80, and VS Code’s GitLens shows the same inline as your cursor moves. Blame tells you which commit changed the line; to see the whole change, follow it with git log -p or git show <sha>.

Finally, the two verbs that replaced an overloaded one. git checkout did too many unrelated jobs, switching branches, creating branches, and discarding file changes, which made it easy to run the destructive version by accident. Modern Git split it in two, each verb doing one job:

  • git switch <branch> changes to an existing branch; git switch -c <branch> creates one and switches to it.
  • git restore <file> discards uncommitted changes to a file (the destructive one, now clearly named); git restore --staged <file> unstages a file without touching its contents.

git checkout still works, but prefer switch and restore: one verb per job means you can’t turn a branch switch into a lost edit by mistake. They shed their “experimental” label in Git 2.51; a tutorial that still calls them experimental is out of date.

The hard part of a real incident is rarely typing the command; it’s the ten-second window where your pulse is up and you have to pick the right one. This section trains that match from situation to move.

Start with the most common case: you committed to the wrong branch, straight onto main or onto the wrong feature branch. Which fix you need is a zone question: have you pushed yet?

If you haven’t pushed, the commit is local-only, so you can rewrite freely. Grab its hash with git log, undo it with git reset --hard HEAD~N (where N is how many commits you misplaced), switch to where it belongs with git switch -c correct-branch, and replay it with git cherry-pick <hash>. If you have pushed but no pull request exists yet, run the same sequence plus one step: after the reset, push the original branch with git push --force-with-lease so the remote drops the misplaced commit too. If a step goes sideways, the reflog is your net.

The walker below is your triage trainer. Start at the top, “what went wrong?”, and click down to the recovery move. Notice the order of the questions: what happened, then where the commit lives, then whether it’s pushed. Walk every branch until the path feels automatic.

Something went wrong — what's the move?

The secret case deserves a sentence more, because the instinct is wrong. Your reflex is to scrub the secret from history, but it leaked the instant it reached the remote. Anyone watching the repository already has it, so deleting it later changes nothing. The real fix is to rotate the secret: revoke the leaked credential and issue a new one. Cleaning history with git filter-repo or BFG is follow-up hygiene, never the primary response.

To lock in the wrong-branch rescue, put its not-pushed path in order.

You committed to the wrong branch and haven't pushed. Order the rescue. Drag the items into the correct order, then press Check.

git log — copy the hash of the misplaced commit.
git reset --hard HEAD~1 — undo the commit on this branch.
git switch -c correct-branch — create and move to the right branch.
git cherry-pick <hash> — replay the commit where it belongs.

The next lesson moves up from the command line to the pull request: how to package a change so a reviewer can actually review it.

The official Git documentation is solid reference once you know what you’re looking for, and Atlassian’s rewriting-history guide is a readable secondary source on the rebase and amend family.