Skip to content
M04Git for investigating and undoingLesson 3 of 3

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.

Goal
Pick the right way to undo a change depending on whether it’s already shared, and recover work that seems lost.

Friday, 6 p.m. A Linkr deploy breaks custom domains. The developer who merged the change wants it gone fast, so they run:

Terminal window
git reset --hard HEAD~2
git push --force

Custom 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.

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]
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.

Terminal window
# Undo a normal commit (a squashed PR, for example)
git revert a41f3c2
git push
# Undo a merge commit: -m 1 keeps the main side (parent 1)
git revert -m 1 8c0d9e7

On 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:

Terminal window
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.

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:

Terminal window
git reflog
1c2d3e4 HEAD@{0}: reset: moving to HEAD~3
9f8e7d6 HEAD@{1}: commit: add domain verification job
5a4b3c2 HEAD@{2}: commit: add domains table
...

The work is at HEAD@{1}. To get it back:

Terminal window
# Option 1: point the current branch back to it
git reset --hard HEAD@{1}
# Option 2 (safer): create a new branch there and look before deciding
git branch rescue 9f8e7d6

After 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.

On your own feature branch, after a rebase, a force push is normal. Use the version that checks before overwriting:

Terminal window
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:

Terminal window
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.

  • Reverting to hide a secret. If an API key went into a pushed commit, revert removes 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 --hard with uncommitted work. Run git status before it. If anything there matters, git stash or a WIP commit first.
  • --hard when you meant --soft. To redo the last commit, you almost always want to keep the changes. --soft or --mixed loses 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 -fd or git push --force and 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.
  1. Turn on Block force pushes for main.
  2. Create the pushf alias above.
  3. Practice once, in a throwaway repository: make three commits, git reset --hard HEAD~3, and get them back with git reflog. It takes two minutes, and it’s better to learn it there than during an incident.

Go deeper

Materials in English unless noted.

  • Book
    Reset DemystifiedPro Git (Scott Chacon, Ben Straub)

    The three trees (HEAD, index, working tree) and exactly what each reset mode moves.

  • Docs
    How 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
    git-reflogGit

    Reflog syntax, expiry and the HEAD@{n} references.

  • Article
    Oh Shit, Git!?!Katie Sylor-Miller

    Short recipes for the most common Git mistakes, written in plain language.

  • Docs
    Reverting a pull requestGitHub

    How the Revert button works and its limitations.