The Safety Net You Forgot You Had: Reflog
Most developers learn Git as "commit, push, pull, merge." That's enough to get by, and it's also why a single bad reset --hard can ruin an afternoon. The first tool worth internalizing is reflog.
Git keeps a local journal of every position HEAD has ever occupied — every commit, checkout, rebase, reset, and merge. This journal is the reflog, and it is the reason "I deleted my work" is almost never true.
# Show every move HEAD has made, newest first
git reflog
# You'll see entries like:
# 4f2a1b3 HEAD@{0}: reset: moving to HEAD~3
# 9c8d7e6 HEAD@{1}: commit: add payment retry logic
# 9c8d7e6 HEAD@{2}: checkout: moving from main to feature/retry
# Jump back to where you were before the disaster
git reset --hard 9c8d7e6
# Or resurrect it as a branch so you don't lose your footing again
git branch rescue-payment-work 9c8d7e6
Two important caveats. First, the reflog is local — it is not pushed, and a fresh clone has none. Second, entries expire: reachable history is kept for 90 days, unreachable objects for 30. Within that window, though, you are effectively immune to your own mistakes.
The practical habit: when something goes wrong, stop typing Git commands and run git reflog first. Every subsequent command you run pushes the state you want to recover one line further down.
Finding the Commit That Broke Everything
"It worked last week" is the most common bug report in software. If the codebase has a hundred commits since then, reading them one by one is a waste of a day. git bisect does a binary search instead — with 1000 commits, it finds the culprit in about 10 steps.
# Start the search
git bisect start
# Mark the current state as broken
git bisect bad HEAD
# Mark a commit you know was fine (a tag, a hash, or a date)
git bisect good v2.4.0
# Git checks out a commit in the middle. Test it, then tell Git:
git bisect good # or: git bisect bad
# ...repeat until Git prints "X is the first bad commit"
git bisect reset # always remember to clean up
The real power shows up when you automate the test itself. If you have any command that exits 0 on success and non-zero on failure — a unit test, a build, a curl against a local server — hand it to bisect and walk away:
git bisect start HEAD v2.4.0
git bisect run npm test
# Ten minutes later:
# 7a3f9c1 is the first bad commit
# commit 7a3f9c1
# Author: someone
# Date: Tue Sep 8 14:22:10 2026
# refactor: move timeout config into client
I've used this to find regressions in codebases I had never seen before. It doesn't require understanding the system — only a reliable pass/fail signal. That's a remarkably low bar for the amount of time it saves.
Two Branches at Once Without Stashing
The usual way to switch context is git stash, switch branch, do the work, switch back, and pray the stash applies cleanly. git worktree removes the whole dance by checking out a branch into a separate directory, sharing the same repository.
# Check out the hotfix branch into a sibling folder
git worktree add ../hotfix-202 hotfix/login-500
cd ../hotfix-202
# Full working directory, own node_modules if you want, same .git
npm install && npm test
# When you're done
git worktree remove ../hotfix-202
Where this really earns its keep is long-running work. I keep a worktree for the production branch so I can diff behavior between versions without touching my feature branch, and another for reviewing large pull requests — the PR gets its own folder, and my in-progress work stays exactly where it was.
One rule: a branch can only be checked out in one worktree at a time. If Git refuses, something already has it, which is usually valuable information.
Rewriting History Before Anyone Sees It
Interactive rebase has a reputation for being dangerous. It's dangerous in exactly one situation: rewriting commits that other people have already pulled. On your own unpushed branch, it's the difference between a history that reads like a lab notebook and one that reads like documentation.
# Rework the last 5 commits
git rebase -i HEAD~5
# pick 4f2a1b3 add user endpoint
# pick 9c8d7e6 fix typo
# pick 1a2b3c4 add validation
# pick 5d6e7f8 fix typo again
# pick 8h9i0j1 update tests
The verbs worth knowing beyond the defaults:
- reword — change the commit message without touching the diff
- squash — merge into the commit above, keeping both messages
- fixup — merge into the commit above, discarding this message (perfect for "fix typo")
- drop — delete the commit entirely
- edit — pause here so you can amend the content or split it apart
You can also let Git prepare the todo list for you. Commit with --fixup and rebase with --autosquash, and the fix lands next to the commit it fixes, already marked:
# You know the typo belongs to 4f2a1b3
git commit --fixup 4f2a1b3
git rebase -i --autosquash HEAD~6
# Git has already arranged and labelled it for you
Moving a Single Commit
Merging takes everything; cherry-picking takes one commit. It's the right tool when a fix on main needs to reach a release branch, but the rest of main doesn't.
# Apply one commit onto the current branch
git cherry-pick 7a3f9c1
# Apply several, or a range
git cherry-pick 7a3f9c1 8b2c3d4
git cherry-pick main~3..main~1
# -x records where it came from — always use this on shared branches
git cherry-pick -x 7a3f9c1
Cherry-picking creates a new commit with a new hash. That means the same logical fix can exist twice in history, and merging those branches later can produce confusing conflicts. Use it deliberately, prefer -x so the trail is visible, and reach for a proper merge when you're moving more than a commit or two.
Searching History Like a Database
git log is far more than a list of commits. These four queries cover most of what you'd ever want to ask:
# Which commits changed the number of retries? (pickaxe: count of occurrences)
git log -S "MAX_RETRIES" --oneline
# Which commits touched a line matching this regex?
git log -G "retry\(.*\)" --oneline
# Which commits mention the ticket in the message?
git log --grep="PAY-142" --oneline
# Full history of one function, with diffs
git log -L :calculateBackoff:src/retry.js
The last one is the hidden gem: -L traces a single function or line range across renames and shows you every change that ever touched it. When you're staring at unfamiliar code and wondering "why on earth is it written this way," that flag usually answers the question in seconds.
Combine with --author, --since, and path filters to narrow further: git log --since="3 weeks ago" -- src/payments/.
Reset, Revert, and Restore Are Not the Same
Confusing these three causes more lost work than any other Git concept. The distinction:
- revert — creates a new commit that undoes an old one. History is preserved, so this is the safe choice on any shared or deployed branch.
- reset — moves the branch pointer, rewriting history.
--softkeeps your changes staged,--mixed(default) keeps them in the working tree,--hardthrows them away. Use it on local work only. - restore — the modern, single-purpose command for discarding changes in files. It doesn't touch history at all.
# Undo a commit that's already on main, safely
git revert 7a3f9c1
# Unstage a file but keep the edits
git restore --staged config.js
# Throw away local edits to one file
git restore config.js
# Undo the last local commit but keep everything staged
git reset --soft HEAD~1
Rule of thumb I've stuck to for years: if it's been pushed, revert; if it's still yours, reset.
Aliases That Pay for Themselves
You don't need an elaborate dotfiles repository. Four aliases cover the vast majority of daily friction:
git config --global alias.lg "log --graph --oneline --decorate --all"
git config --global alias.undo "reset --soft HEAD~1"
git config --global alias.st "status -sb"
git config --global alias.amend "commit --amend --no-edit"
# And one safety setting: never --force, only --force-with-lease
git config --global alias.pushf "push --force-with-lease"
--force-with-lease refuses to overwrite work that appeared on the remote since your last fetch. It is --force with a seatbelt, and there is no good reason to use the plain version.
Habits That Prevent the Disaster
Tools are the fallback; habits are the actual fix. These cost seconds and have saved me hours:
- Read your own diff before committing.
git diff --stagedcatches stray debug logs, commented-out code, and credentials more often than any linter. - Commit the fix separately from the feature. Future-you running
bisectorcherry-pickwill be grateful. - Tag releases.
git bisect good v2.4.0only works because someone typedgit tag. - Branch names are documentation.
fix/payment-timeout-500tells the next person something.test123does not. - When a merge goes sideways, stop.
git merge --abortandgit rebase --abortexist precisely so you can back out cleanly instead of fighting.
Git rewards the people who understand that nothing is really deleted. Once you believe that — because you've watched reflog bring something back — the whole tool stops feeling fragile and starts feeling like the safety harness it was designed to be.