How to Undo Almost Anything in Git: Reset, Revert, Reflog (2026)

Oct 31, 2026 · 13 min · Ajay Kumar

This guide is for anyone who has just typed a Git command and immediately wished they had not. By the end you will know which command undoes which mistake, how to get back commits that git reset --hard appeared to destroy, how to undo a commit that is already pushed without breaking your teammates, and how to scrub a leaked secret from history properly. Every command and output below was run on Git 2.54.0 (Apple Git-157) on macOS; upstream Git is at v2.56.0 and none of these commands changed between the two. You need a terminal, Git 2.23 or later (for git restore and git switch), and for the last step, git filter-repo 2.47.0.

Step 1: Work out where the mistake lives

Git keeps your work in four places: the working tree (files on disk), the index (what you staged), local commits, and commits that have been pushed. The right undo depends entirely on which of those the mistake has reached, and the most important line is between local and pushed. Local history is yours to rewrite. Pushed history belongs to everyone who has pulled it.

git status --short          # working tree and index: ' M' unstaged, 'M ' staged
git log --oneline -5        # recent local commits
git status -sb | head -1    # '## main...origin/main [ahead 2]' means 2 commits not pushed yet
Where is the mistake? Pick the undo by how far it has travelled Working tree edited, not staged Index staged with git add Local commit committed, not pushed Pushed commit others may have pulled it git restore FILE discards the edit git restore --staged unstages, keeps the edit amend or git reset rewrites local history git revert SHA new commit, no rewrite Commit seems lost after reset or rebase? git reflog, then git reset --hard HEAD@{n} or git branch Pushed a secret? rotate it first, then git filter-repo and force-push Rule of thumb: rewrite only what nobody else has; for shared history, add a commit that undoes it.
Which undo command fits which stage of a change, based on the Git documentation for restore, reset, revert and reflog and GitHub's sensitive data removal guide.

Step 2: Discard or unstage changes with git restore

git restore (added in Git 2.23 to split the file-level half of checkout into its own command) handles the first two boxes. The documentation defaults to restoring the working tree from the index; --staged restores the index from HEAD.

git add app.txt
git status --short
# M  app.txt         <- staged

git restore --staged app.txt     # undo "git add", keep the edit
git status --short
#  M app.txt         <- unstaged, edit still on disk

git restore app.txt              # throw the edit away (this one is NOT recoverable)
git status --short
#                    <- clean

# Restore one file as it was two commits ago, without moving HEAD:
git restore --source=HEAD~2 app.txt

Uncommitted edits that you discard with git restore never entered Git's object store, so the reflog cannot save you. If you are not sure, git stash push -m "just in case" first; the stash is a commit and can be recovered.

Step 3: Fix the last commit with git commit --amend

Wrong message, forgotten file, typo in the code you just committed: amend replaces the tip commit with a new one.

git add forgotten-file.yaml
git commit --amend --no-edit          # same message, new content
git commit --amend -m "Fix: correct message"   # new message

Amend creates a new commit ID. If the original was already pushed, you will need a force-push (Step 7), so on a shared branch prefer a follow-up commit.

Step 4: Undo local commits with git reset --soft, --mixed or --hard

git reset moves the current branch to another commit. The three modes differ only in what they do to the index and working tree, per the git-reset docs:

Mode Branch pointer Index Working tree Use it to
--soft moved unchanged (changes stay staged) unchanged squash or redo the last few commits
--mixed (default) moved reset (changes unstaged) unchanged uncommit and re-stage selectively
--hard moved reset reset (changes gone) throw the commits and edits away

Real output from two scratch repos. First, soft and mixed, where the last commit modified a file f:

git reset --soft HEAD~1
git status --short
# M  f              <- the commit's change is staged again

git reset            # --mixed against HEAD: unstage everything
git status --short
#  M f              <- still on disk, now unstaged

Then hard, in a fresh repo with three commits (Step 5 continues from this one):

git log --oneline
# 24b950e Add tests
# e503cd4 Add feature
# d7d75e3 Add app
git reset --hard HEAD~2
# HEAD is now at d7d75e3 Add app

--hard is the one people fear, and for committed work the fear is mostly misplaced, because of the next step. For uncommitted edits it really is destructive: run git status before you use it.

Step 5: Recover lost commits with git reflog

Every time HEAD moves (commit, reset, rebase, checkout, merge), Git appends an entry to the reflog. A commit you reset away is unreferenced but still on disk, and the reflog remembers its ID. Continuing from the hard reset above:

git reflog | head -5
# d7d75e3 HEAD@{0}: reset: moving to HEAD~2
# 24b950e HEAD@{1}: commit: Add tests
# e503cd4 HEAD@{2}: commit: Add feature
# d7d75e3 HEAD@{3}: commit (initial): Add app

git reset --hard HEAD@{1}
# HEAD is now at 24b950e Add tests

Prefer not to move your branch? Put the lost commit on a new one instead: git branch rescued 24b950e. The same trick recovers a deleted branch: git branch -D prints Deleted branch topic (was 1a2b3c4)., and git branch topic 1a2b3c4 brings it back. A botched rebase is undone with git reset --hard ORIG_HEAD straight afterwards, or the reflog entry from before rebase (start).

The safety net has limits. The reflog is local to your clone, and git gc expires entries after 90 days by default (gc.reflogExpire), or 30 days for entries no longer reachable from the branch tip (gc.reflogExpireUnreachable), and prunes loose unreachable objects older than two weeks. If the reflog has already expired, git fsck --lost-found can sometimes still find dangling commits.

Step 6: Undo a pushed commit with git revert

For anything on a shared branch, do not rewrite: git revert records a new commit that applies the inverse change. History stays intact, nobody's clone breaks, and the undo is itself reviewable.

git revert --no-edit HEAD
# [main 130ee08] Revert "Add tests"
#  1 file changed, 1 deletion(-)

git revert --no-edit a1b2c3d..HEAD     # revert a range (oldest exclusive), one commit each
git push origin main

Merge commits need one extra flag, because Git has to know which parent counts as the mainline:

git revert --no-edit HEAD
# error: commit 7c1032ad12c085bbb182d00750e1879e438f51a5 is a merge but no -m option was given.
# fatal: revert failed

git revert -m 1 --no-edit HEAD         # parent 1 = the branch you merged INTO
# [main 3adf7e1] Revert "Merge topic"

One catch the docs spell out: after reverting a merge, merging the same branch again later will not bring those changes back, because Git considers them already merged. Revert the revert (git revert <revert-sha>) before re-merging. This is also why trunk-based teams, as I discuss in the companion piece on Git branching strategies, treat "revert first, debug second" as the standard response to a broken main.

Step 7: Rewrite pushed history safely with --force-with-lease

Sometimes you do need to rewrite something you pushed: a personal feature branch you rebased, or an amend before anyone reviewed it. Never use bare --force. --force-with-lease refuses if the remote branch moved since you last fetched it, and --force-if-includes (Git 2.30+) additionally refuses if you fetched someone else's commits but never integrated them, which covers the IDE that runs git fetch in the background. Both are documented in git-push. From a scratch repo where a teammate pushed after my last fetch:

git commit --amend -m "two, amended"
git push --force-with-lease origin main
#  ! [rejected]        main -> main (stale info)
# error: failed to push some refs to '../remote.git'

git fetch      # a background fetch updates origin/main, defeating a plain lease
git push --force-with-lease --force-if-includes origin main
# hint: Updates were rejected because the tip of the remote-tracking branch has
# hint: been updated since the last checkout. If you want to integrate the
# hint: remote changes, use 'git pull' before pushing again.

Make it the default so you never type the dangerous version by accident:

git config --global alias.pushf "push --force-with-lease --force-if-includes"

Step 8: Back out of a merge or rebase conflict

A conflict is not a mistake until you commit a bad resolution. Until then you can always go back:

git merge topic
# CONFLICT (content): Merge conflict in app.txt
# Automatic merge failed; fix conflicts and then commit the result.
git status --short
# UU app.txt

git merge --abort        # back to exactly where you were
git rebase --abort       # same for a rebase in progress
git cherry-pick --abort  # and for a cherry-pick

To resolve instead, edit the markers or take one side wholesale, then continue:

git checkout --ours app.txt      # keep your branch's version (during a merge)
git checkout --theirs app.txt    # keep the incoming version
git add app.txt
git commit                       # or: git rebase --continue

During a rebase, --ours and --theirs are swapped (ours is the branch you are rebasing onto), which has confused everyone at least once. If you committed a bad merge resolution locally, git reset --hard ORIG_HEAD undoes the whole merge; if it is pushed, revert it with -m 1 as in Step 6. Enabling git config --global rerere.enabled true makes Git remember resolutions so repeated rebases stop asking.

Step 9: Remove a secret from Git history with git filter-repo

First, and this is not optional: rotate or revoke the credential. GitHub's sensitive data guide says so before anything else, because bots scrape public pushes and forks and old clones keep the data whatever you do. A git revert does not help here, since the secret stays in the earlier commit.

Then rewrite with git filter-repo, the tool GitHub's guide is built around (the git-filter-branch manual itself points people to it). Version 2.47.0 (current on PyPI and Homebrew) added --sensitive-data-removal, which fetches all refs and reports the first changed commits and orphaned LFS objects for the cleanup.

brew install git-filter-repo        # or: pipx install git-filter-repo
git-filter-repo --help | grep -- --sensitive-data-removal   # no match means older than 2.47
# (--version prints a 12-character hash of the script, not "2.47.0")

# Work in a FRESH clone; filter-repo refuses otherwise (that's a feature).
git clone https://github.com/OWNER/REPO.git repo-clean && cd repo-clean

# Option A: delete a file from every commit (use the full path in the repo)
git filter-repo --sensitive-data-removal --invert-paths --path config/prod.env

# Option B: replace the secret text everywhere it appears
# ../replacements.txt contains one expression per line, e.g.:
#   AKIAEXAMPLEKEY123==>REDACTED
#   regex:ghp_[A-Za-z0-9]{36}==>REDACTED
git filter-repo --sensitive-data-removal --replace-text ../replacements.txt

cat .git/filter-repo/changed-refs | wc -l   # how many refs (incl. PRs) changed
git push --force --mirror origin           # refs/pull/* will fail: that's expected

Lines in the replacements file without ==> are replaced with ***REMOVED***, per the manual. After the push, GitHub still holds the old commits in pull request refs and cached views, so its guide tells you to contact GitHub Support with the first changed commits that filter-repo printed. Every collaborator must re-clone; anyone who pushes from an old clone puts the secret straight back. Signatures on rewritten commits are lost and open PRs get new diffs, so merge or close them first. To stop the next one, see How to Stop Secrets Leaking Into Git.

Troubleshooting

Symptom Likely cause Fix
error: commit ... is a merge but no -m option was given Reverting a merge commit git revert -m 1 <sha> (parent 1 is the mainline)
! [rejected] main -> main (stale info) Remote moved since your last fetch; lease refused git fetch, inspect origin/main, rebase, then push again
Updates were rejected because the tip of the remote-tracking branch has been updated --force-if-includes saw fetched commits you never integrated git pull --rebase (or rebase onto origin/<branch>), then push
Commits gone after git reset --hard Branch pointer moved, commits unreferenced git reflog, then git reset --hard HEAD@{n} or git branch rescued <sha>
Re-merging a branch after reverting its merge brings nothing back Git treats the reverted changes as already merged Revert the revert commit, then merge again
Aborting: Refusing to destructively overwrite repo history since this does not look like a fresh clone filter-repo's fresh-clone safety check Clone again and rerun; use --force only if you have a backup
Secret still visible on GitHub after force-push Cached views and refs/pull/* still reference old commits Contact GitHub Support with the first changed commits from filter-repo
fatal: ambiguous argument 'HEAD~1': unknown revision Repository has only one commit, so there is no parent Use git update-ref -d HEAD to uncommit the root commit, or amend it
Discarded edits not in the reflog Uncommitted changes were never stored as objects Check editor local history; next time git stash before restore or reset --hard

FAQ

What is the difference between git reset and git revert?

git reset moves your branch pointer backwards and rewrites local history, so the undone commits disappear from the branch. git revert keeps history and adds a new commit that applies the inverse change. Use reset for commits only you have; use revert for anything already pushed to a shared branch.

How do I undo the last commit but keep my changes?

Run git reset --soft HEAD~1 to keep the changes staged, or git reset HEAD~1 (mixed, the default) to keep them unstaged in your working tree. Either way nothing on disk is lost, and the reflog still holds the old commit.

Can I recover a commit after git reset --hard?

Usually yes. Run git reflog, find the entry before the reset, and run git reset --hard HEAD@{n} or create a branch at that commit ID. Reflog entries expire after 30 to 90 days by default, and the reflog only exists in the clone where the reset happened.

How do I remove a password from Git history?

Rotate the password first, then use git filter-repo --sensitive-data-removal with --replace-text or --invert-paths in a fresh clone, force-push all refs, ask your host (GitHub Support, for example) to purge cached references, and have every collaborator re-clone.

When something goes wrong on a shared branch, the first thing I check is git status -sb, because "ahead" or "up to date" decides between reset and revert, and I reach for revert far more often than for anything that needs a force-push. For local disasters, git reflog has rescued more of my afternoons than any backup. The official references worth bookmarking are the git-reset, git-revert, git-reflog and git-push manuals, and GitHub's guide to removing sensitive data.

Related: How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI, Secrets Sprawl 2026: 28.65M Leaked Keys and 64% Still Valid Years Later and Git Branching Strategies 2026: Trunk-Based vs GitFlow vs GitHub Flow.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related