Git Reset vs Git Revert: Picking the Wrong One Can Ruin Your Day
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Confusion That Causes Incidents
A developer pushes a bad commit to main. They want to undo it. They google "git undo commit," find git reset --hard HEAD~1, run it, and then git push --force origin main. Three other developers who have pulled since the original push now have diverged histories. The next time any of them pushes, Git rejects it. They pull to reconcile, and the bad commit comes back. The developer force-pushes again. The cycle repeats.
This scenario plays out on teams regularly because git reset and git revert look like they do the same thing — both "undo" changes — but they operate on entirely different models.
What git reset Does
git reset moves the branch pointer (HEAD) to a different commit. It changes where your current branch points.
# Before reset:
# A --- B --- C --- D ← HEAD (main)
git reset HEAD~2
# After reset:
# A --- B ← HEAD (main)
# (C and D still exist in object store, just unreachable from main)
The --soft, --mixed (default), and --hard flags control what happens to your working tree and index during the move:
# --soft: move HEAD, keep C and D's changes staged
git reset --soft HEAD~2
# --mixed (default): move HEAD, keep changes in working tree (unstaged)
git reset HEAD~2
# --hard: move HEAD, discard C and D's changes entirely
git reset --hard HEAD~2
Critical: in all three cases, the branch pointer moved. Commits C and D are no longer part of the branch history, even though they still exist in Git's object store until garbage collection runs.
This is why pushing after git reset requires --force. The remote branch still points to D. Your local branch now points to B. Git sees a non-fast-forward divergence and refuses to push without force.
What git revert Does
git revert creates a new commit that inverts the changes from a specific commit. It does not move any pointers. It appends to history.
# Before revert:
# A --- B --- C --- D ← HEAD (main)
git revert C --no-edit
# After revert:
# A --- B --- C --- D --- C' ← HEAD (main)
# where C' contains the inverse of C's changes
The branch now has five commits. C is still there. C' undoes C's effect. The history is honest about what happened.
Pushing after git revert is a normal fast-forward push. No force required. Anyone who pulls gets C' and their history converges correctly.
The Decision Rule
Use git reset when:
- The commit(s) you're undoing haven't been pushed yet
- You're on a personal branch that nobody else has pulled
- You explicitly need to rewrite history (squashing commits before a PR)
Use git revert when:
- The commit has been pushed to a shared branch
- Other people may have pulled since the commit
- You need an auditable record that a change was made and then undone
This is not a matter of preference. Using git reset on a shared branch and force-pushing is a decision that affects every other developer who has pulled from that branch. It should require team awareness and coordination, not a reflex.
The Danger of --hard
git reset --hard is the riskiest form because it discards working tree changes in addition to moving the pointer.
# This discards any uncommitted work in your working tree
git reset --hard HEAD~1
If you had uncommitted changes (staged or unstaged) when you ran this, they're gone. Not in reflog (reflog tracks commits, not working tree state). Not recoverable through normal means.
Before running --hard, always check what you have:
git status # check for uncommitted changes
git stash # stash them if you want to keep them
git reset --hard HEAD~1 # now it's safe
Reverting a Range of Commits
For undoing multiple sequential commits:
# Revert commits D, C, B (in reverse order to avoid conflicts)
git revert HEAD~3..HEAD --no-edit
This creates three revert commits. If the commits are related and you'd prefer one revert commit:
git revert HEAD~3..HEAD --no-commit
# stages all the inversions without committing
git commit -m "revert: undo last three commits (accidental push to main)"
Reverting a Merge Commit
Merge commits have two parents, so git revert needs to know which parent to treat as the mainline:
# -m 1 = revert to parent 1 (the branch that was merged INTO)
git revert -m 1 <merge-commit-sha>
One non-obvious consequence: if you revert a merge commit and later want to re-apply that branch's changes, you need to revert the revert first. The revert told Git "this branch's changes are not wanted" — simply re-merging the branch won't bring the changes back because Git considers them already merged.
The Force-With-Lease Safety Net
If you do need to force push (on a personal branch, or after coordination with the team), use --force-with-lease instead of --force:
# Safe: fails if someone else pushed after you last fetched
git push --force-with-lease origin feature/my-branch
# Dangerous: overwrites regardless of what the remote has
git push --force origin feature/my-branch
--force-with-lease checks whether the remote is at the SHA you last fetched. If someone else pushed in the interim, it fails. This prevents the scenario where your force push overwrites someone else's work that arrived after your last fetch.
The practical rule is simple: revert on shared branches, reset on private ones. Knowing the difference saves you from explaining to your team why their local history is broken.