Advanced Git: Reflog, Cherry-Pick, and Disaster Recovery

TT
Advanced Git: Reflog, Cherry-Pick, and Disaster Recovery

Advanced Git: Reflog, Cherry-Pick, and Disaster Recovery

Every developer has had the gut-drop moment: git reset --hard on the wrong branch, a rebase that went wrong, or a branch accidentally deleted. Git's architecture makes these recoverable if you know where to look. This guide covers the tools that turn Git disasters into minor inconveniences, plus the advanced operations that separate proficient Git users from experts.


The Reflog: Git's Safety Net

The reflog records every movement of HEAD—every commit, checkout, rebase, reset, and merge. It retains this history for 90 days by default, independent of branch state. Even commits with no branch pointing to them remain in the reflog and are reachable.

bash
git reflog
# Output:
# abc1234 HEAD@{0}: reset: moving to HEAD~1
# def5678 HEAD@{1}: commit: Add payment validation
# ghi9012 HEAD@{2}: commit: Fix auth middleware
# jkl3456 HEAD@{3}: checkout: moving from main to feature/payments

Each line shows:

  • The commit hash at that point
  • HEAD@{N} — the state of HEAD N moves ago
  • The operation that moved HEAD

Recovering a Dropped Commit

bash
# You did: git reset --hard HEAD~3 and lost 3 commits
# Find the lost commits in reflog
git reflog | head -20

# The last good state was HEAD@{3}
git reset --hard HEAD@{3}
# or by hash:
git reset --hard def5678

# If you want to recover to a new branch without disturbing current state:
git checkout -b recovery-branch HEAD@{3}

Recovering a Deleted Branch

bash
# Accidentally deleted a branch
git branch -D feature/important-work

# Find the last commit that was on the branch
git reflog | grep "feature/important-work"
# abc1234 HEAD@{5}: checkout: moving from feature/important-work to main

# Recreate the branch at that commit
git checkout -b feature/important-work abc1234

Recovering After a Bad Rebase

bash
# Rebase went wrong, history is messed up
git reflog | grep "rebase"
# Find the commit hash just before the rebase started (the ORIG_HEAD)
git reset --hard ORIG_HEAD
# ORIG_HEAD is automatically saved before rebase, reset, and merge operations

Cherry-Pick: Moving Specific Commits

cherry-pick applies the changes from one or more commits onto your current branch, creating new commits with the same changes but different hashes.

bash
# Apply a single commit from another branch
git cherry-pick abc1234

# Apply a range of commits (inclusive)
git cherry-pick abc1234^..def5678

# Apply multiple non-consecutive commits
git cherry-pick abc1234 def5678 ghi9012

# Cherry-pick without committing (stage only)
git cherry-pick --no-commit abc1234

# Keep the original committer info
git cherry-pick -x abc1234  # adds "cherry picked from commit abc1234" to message

Real-World Cherry-Pick Scenarios

bash
# Scenario 1: Hotfix needed on release branch
# A bug was fixed on main (commit abc1234) but you need it on release/2.0 too
git checkout release/2.0
git cherry-pick abc1234
git push origin release/2.0

# Scenario 2: Feature work accidentally committed to main
# Move commits to a feature branch
git log --oneline main~3..main
# abc1234 Add experimental feature
# def5678 More experimental feature

git checkout -b feature/experimental main~3
git cherry-pick def5678 abc1234  # apply in order
git checkout main
git reset --hard main~2  # remove from main

# Scenario 3: Pick only part of a commit
git cherry-pick --no-commit abc1234
git reset HEAD  # unstage everything
git add specific-file.ts  # stage only what you want
git commit -m "Partial cherry-pick: only the relevant change"

Cherry-Pick Conflicts

bash
# When cherry-pick hits a conflict:
git cherry-pick abc1234
# CONFLICT (content): Merge conflict in src/auth.ts

# Fix the conflicts, then:
git add src/auth.ts
git cherry-pick --continue

# Or abort and return to original state:
git cherry-pick --abort

Interactive Rebase: Rewriting History

Interactive rebase lets you reorder, combine, edit, or delete commits before sharing them.

bash
# Rewrite the last 5 commits
git rebase -i HEAD~5

# Rewrite all commits since branching from main
git rebase -i $(git merge-base HEAD main)

The editor opens with a list of commits, oldest at top:

text
pick abc1234 Fix auth middleware
pick def5678 Add validation (forgot to include test)
pick ghi9012 Add test for validation
pick jkl3456 WIP: debugging
pick mno7890 Final fix

# Commands:
# p, pick   = use commit as-is
# r, reword = use commit but edit the message
# e, edit   = pause to amend this commit
# s, squash = combine into previous commit
# f, fixup  = like squash but discard this commit's message
# d, drop   = remove this commit entirely
text
# Reorder, combine the test with the feature, remove WIP:
pick abc1234 Fix auth middleware
pick def5678 Add validation
f    ghi9012 Add test for validation    ← fixup: merges into def5678
d    jkl3456 WIP: debugging            ← drop: removes entirely
r    mno7890 Final fix                 ← reword: edit the message

Common Interactive Rebase Patterns

bash
# 1. Squash feature branch into one commit before merge
git rebase -i main
# Mark all but first as 'squash'

# 2. Fix a commit that's not the most recent
git rebase -i HEAD~4  # open interactive rebase going back 4 commits
# Change 'pick' to 'edit' for the commit you want to fix
# Git pauses at that commit:
git add fixed-file.ts
git commit --amend
git rebase --continue

# 3. Split a commit into two
git rebase -i HEAD~3
# Mark the commit you want to split as 'edit'
# Git pauses:
git reset HEAD~1  # unstage the commit (keep changes)
git add part-one.ts
git commit -m "First part"
git add part-two.ts
git commit -m "Second part"
git rebase --continue

Never rebase commits that have been pushed to a shared branch. Rebase rewrites commit hashes; anyone who has pulled those commits will have a diverged history.


Git Bisect: Binary Search for Bugs

git bisect automates finding which commit introduced a bug using binary search. Given a known-good commit and a known-bad commit, it cuts the range in half with each test.

bash
# Start bisect
git bisect start

# Mark current state as bad
git bisect bad HEAD

# Mark a known-good commit (e.g., last release tag)
git bisect good v1.2.0

# Git checks out the midpoint commit
# Run your test to check if the bug is present:
npm test  # or: ./reproduce-bug.sh

# Tell Git the result
git bisect good  # bug not present in this commit
# or
git bisect bad   # bug IS present in this commit

# Git continues bisecting — repeat until:
# "abc1234 is the first bad commit"

# End bisect (returns to original HEAD)
git bisect reset

Automated Bisect

bash
# Write a script that exits 0 for good, 1 for bad
cat > /tmp/test-bug.sh << 'EOF'
#!/bin/bash
npm run build 2>&1 | grep -q "ERROR" && exit 1 || exit 0
EOF
chmod +x /tmp/test-bug.sh

# Run automated bisect
git bisect start
git bisect bad HEAD
git bisect good v1.0.0
git bisect run /tmp/test-bug.sh
# Git runs your script at each midpoint automatically
# Reports: "abc1234 is the first bad commit"

With 100 commits in range, bisect finds the culprit in 7 steps (log₂(100) ≈ 7).


Git Worktrees: Multiple Branches Simultaneously

Worktrees let you check out multiple branches simultaneously in separate directories, sharing the same .git folder. No stashing, no switching branches.

bash
# Add a worktree for a different branch
git worktree add ../hotfix-1.0 release/1.0
# Now ~/project/ is on your current branch
# And ~/hotfix-1.0/ is on release/1.0

# Work on the hotfix in the separate directory
cd ../hotfix-1.0
git checkout -b hotfix/payment-crash
# Make changes, commit, push — all independent of your main workspace

# List worktrees
git worktree list
# /home/you/project       abc1234 [main]
# /home/you/hotfix-1.0    def5678 [hotfix/payment-crash]

# Remove a worktree when done
git worktree remove ../hotfix-1.0

Worktrees are perfect for:

  • Reviewing a PR without abandoning in-progress work
  • Running tests on a different branch simultaneously
  • Maintaining a release branch while developing on main

Useful Advanced Commands

bash
# Find which commit deleted a function
git log -S "functionName" --all --source  # commits that added/removed the string

# Find commits by message text
git log --all --grep="fix payment"

# Show what a file looked like N commits ago
git show HEAD~5:src/auth/middleware.ts

# Compare a file between branches
git diff main..feature -- src/api/users.ts

# Find all unreachable commits (includes dropped commits from resets)
git fsck --unreachable | grep commit
# unreachable commit abc1234

# Recover a totally orphaned commit
git cat-file -p abc1234  # inspect it
git checkout -b recovered abc1234  # recover it

# Stash with a message (much more useful)
git stash push -m "WIP: payment form validation" -- src/payment/

# List stashes
git stash list
# stash@{0}: On feature/payments: WIP: payment form validation
# stash@{1}: On main: debugging auth

# Apply a specific stash without removing it
git stash apply stash@{1}

# Apply and pop a specific stash
git stash pop stash@{1}

Frequently Asked Questions

Q: I ran git push --force and overwrote my colleague's commits. How do we recover?

Your colleague still has those commits locally (unless they also ran destructive commands). Have them run git log --oneline origin/main..HEAD to see their local-only commits, then git push --force-with-lease origin main won't work because the remote has moved. The clean recovery: your colleague creates a branch with their commits, force-pushes to the remote to restore the lost commits (git push origin HEAD:main --force), then you both pull and merge. In the future, use --force-with-lease instead of --force—it refuses the force-push if the remote has commits you haven't fetched, preventing exactly this scenario.

Q: What is the difference between git reset --soft, --mixed, and --hard?

All three move the branch pointer to the specified commit. The difference is what happens to the changes: --soft keeps changes staged (ready to commit again); --mixed (the default) unstages changes but keeps them in your working directory; --hard discards all changes permanently—the files in your working directory are overwritten. Use --soft to undo the last commit but keep the work; --mixed to undo a commit and unstage for re-evaluation; --hard only when you want to completely throw away changes (and remember, if you've committed first, the reflog can still rescue you).

Q: When should I use git merge vs git rebase?

Use merge for integrating completed branches into main—it preserves the full history of when features were developed and merged. Use rebase to update a feature branch with the latest main before opening a PR—it produces a linear history that is easier to review and git log is cleaner. The rule many teams follow: rebase feature branches locally, merge them into main. Never rebase shared branches (anything others have pulled). With merge, history is complete but noisy; with rebase, history is clean but loses the development timeline.

Q: How do I find a commit that was on a branch I deleted two weeks ago?

The reflog saves branch deletions. Run git reflog | grep "branch-name" to find the last commit hash on that branch. If the branch was deleted more than 90 days ago, the reflog may have expired, but git fsck --unreachable | grep commit lists all commits not reachable from any ref—they may still be in the object store until the next git gc. Run git show <hash> on candidates to identify your commit, then git checkout -b restored <hash> to recover it.