Undoing safely
When to rewrite history and when to add to it, how revert and reset differ, and how reflog recovers commits you thought were gone.
The problem
Section titled “The problem”Friday, 6 p.m. A Linkr deploy breaks custom domains. The developer who merged the change wants it gone fast, so they run:
git reset --hard HEAD~2git push --forceCustom domains work again. But in the 20 minutes between the bad merge and the reset, a teammate had merged a fix for Stripe webhook signatures. HEAD~2 went back past it, and the force push removed it from main. Nobody noticed until Monday, when failed webhooks had left 40 customers with a paid subscription and a free account.
Undoing the deploy was the right call. reset was the wrong tool, because it rewrites history, and other people already had that history.
Two ways to undo
Section titled “Two ways to undo”Every undo in Git either rewrites history or adds to it.
Rewriting moves the branch to another commit as if the commits after it never happened. That’s what reset, commit --amend and rebase do, and it’s fine for commits that only exist on your machine or on your own branch.
Adding creates a new commit that cancels the effect of an old one. That’s revert. The old commit stays, and so does everything after it, which makes it the only safe option for anything already on main.
flowchart LR
B{Committed?} -- no --> C[git restore]
B -- yes --> D{Pushed to a<br/>shared branch?}
D -- yes --> E[git revert]
D -- no --> F{Keep the<br/>changes?}
F -- yes --> G[git reset --soft<br/>or --mixed]
F -- no --> H[git reset --hard]
Quick reference
Section titled “Quick reference”| Situation | Command |
|---|---|
| Discard edits to a file | git restore src/file.ts |
| Unstage a file | git restore --staged src/file.ts |
| Fix the last commit (message or a missing file), not pushed | git commit --amend |
| Undo the last local commit, keep the changes staged | git reset --soft HEAD~1 |
| Undo the last local commit, keep the changes unstaged | git reset HEAD~1 |
| Throw away local commits and changes | git reset --hard <sha> |
| Undo a commit that’s already on main | git revert <sha> |
| Undo a merged PR (merge commit) | git revert -m 1 <merge-sha> |
| Get back something “lost” | git reflog |
The three reset modes differ only in what they keep. All of them move the branch. --soft keeps your changes staged, --mixed (the default) keeps them in the working tree but unstaged, and --hard discards them. --hard is the only one that can destroy work, and it destroys exactly the part reflog can’t recover: changes you never committed.
Revert in practice
Section titled “Revert in practice”# Undo a normal commit (a squashed PR, for example)git revert a41f3c2git push
# Undo a merge commit: -m 1 keeps the main side (parent 1)git revert -m 1 8c0d9e7On GitHub, a merged PR has a Revert button that opens a new PR with the revert. It goes through review and CI like any other change, which is what you want at 6 p.m. on a Friday.
Reverting a merge commit has a catch. Git considers the original commits already merged, so merging the same branch again later doesn’t bring the changes back. To reapply them, you revert the revert:
git revert <sha-of-the-revert>Squash merges avoid most of this: each PR becomes a single commit with a single parent, and reverting it is a plain git revert.
Reflog: Git’s undo history
Section titled “Reflog: Git’s undo history”Git records every position HEAD and each branch has been in, locally, in the reflog. A commit that no branch points to anymore isn’t deleted right away. It stays reachable through the reflog for 30 days by default (90 days for entries still reachable from a branch).
So after a mistaken reset --hard, a botched rebase or a deleted branch:
git reflog1c2d3e4 HEAD@{0}: reset: moving to HEAD~39f8e7d6 HEAD@{1}: commit: add domain verification job5a4b3c2 HEAD@{2}: commit: add domains table...The work is at HEAD@{1}. To get it back:
# Option 1: point the current branch back to itgit reset --hard HEAD@{1}
# Option 2 (safer): create a new branch there and look before decidinggit branch rescue 9f8e7d6After a rebase or merge, ORIG_HEAD points to where the branch was before, so git reset --hard ORIG_HEAD undoes the last one.
Reflog has limits. It only exists on your machine, so it can’t recover what a teammate lost. And it only knows about commits. Edits you never committed and that reset --hard or git checkout . discarded are almost always gone. If they had been staged with git add, git fsck --lost-found can sometimes find the file contents, without names.
So commit early, even if it’s a WIP commit. A local commit can always be reshaped later with reset --soft or rebase. Uncommitted work has no safety net.
If you really need to force push
Section titled “If you really need to force push”On your own feature branch, after a rebase, a force push is normal. Use the version that checks before overwriting:
git push --force-with-lease --force-if-includes--force-with-lease refuses to push if the remote branch has moved since your last fetch. --force-if-includes closes a gap in it: an editor that fetches in the background would otherwise make the lease look up to date. Make it an alias so you never type the bare --force:
git config --global alias.pushf "push --force-with-lease --force-if-includes"On main, a force push should be impossible. In the GitHub ruleset from 4.1, turn on Block force pushes.
Pitfalls
Section titled “Pitfalls”- Reverting to hide a secret. If an API key went into a pushed commit,
revertremoves it from the current code and leaves it in the history, readable by anyone with access. Rotate the key first. Rewriting history comes second, and clones and caches may already have it (M16). reset --hardwith uncommitted work. Rungit statusbefore it. If anything there matters,git stashor a WIP commit first.--hardwhen you meant--soft. To redo the last commit, you almost always want to keep the changes.--softor--mixedloses nothing.- Amending a commit that’s already pushed. Same problem as reset: whoever pulled the original now has a diverging history.
- What AI tends to get wrong: coding agents “clean up” with
git checkout .,git reset --hard,git clean -fdorgit push --forceand take your uncommitted work with them. Commit or stash before starting an agent session, and use your tool’s permission settings to block destructive Git commands. When an assistant suggests a force push to main to “fix” history, the answer is a revert.
Apply it to your project
Section titled “Apply it to your project”- Turn on Block force pushes for main.
- Create the
pushfalias above. - Practice once, in a throwaway repository: make three commits,
git reset --hard HEAD~3, and get them back withgit reflog. It takes two minutes, and it’s better to learn it there than during an incident.
Go deeper
Materials in English unless noted.
- BookReset DemystifiedPro Git (Scott Chacon, Ben Straub)
The three trees (HEAD, index, working tree) and exactly what each reset mode moves.
- DocsHow to revert a faulty mergeLinus Torvalds, Junio C Hamano
Why re-merging after reverting a merge doesn't bring the changes back, and what to do instead.
- Docs
- ArticleOh Shit, Git!?!Katie Sylor-Miller
Short recipes for the most common Git mistakes, written in plain language.
- Docs