The Scenario Matrix: Find Yourself First
Undo commands depend on three questions: which layer the change sits in (working tree / index / commits), whether it is pushed, and whether collaborators exist. Everything below was verified in a real throwaway repository (outputs in the Reproducible Results section), including one often-missed edge: how untracked files interact with reset --hard.
| Scenario | Command | Risk |
|---|---|---|
| Just committed, typo in message | git commit --amend |
Unpushed = zero risk |
| Undo an unpushed commit, keep changes | git reset --soft/--mixed HEAD~1 |
Zero risk (recoverable via reflog) |
| Undo an unpushed commit, discard everything | git reset --hard HEAD~1 |
Uncommitted tracked changes are lost too |
| Undo a pushed commit | git revert <hash> |
Zero risk (adds an inverse commit) |
| Recover commits after a reset | git reflog + git branch rescue <hash> |
Expires (90 days by default) |
| Accidentally deleted an untracked new file | Git cannot help | It never entered the object database |
The last row is worth underlining up front: Git can only recover content that entered the object database. Untracked files (created but never git added) are beyond recovery once deleted — verified experimentally below.
amend: Fix the Last Commit (Message and Content)
git commit --amend folds the current staging area and a new message into a brand-new commit replacing the last one:
echo v1 > a.txt && git add . && git commit -qm "initail commmit" # typo
git commit --amend -qm "initial commit"
git log --oneline # → ad04563 initial commit (hash changed)
The test confirms both effects: the typo is fixed, and the hash is completely different — amend is "a new commit replaces the old one", not an edit. It is therefore only for unpushed commits; amending a pushed one is history rewriting.
amend also folds in whatever is currently staged: if the index holds changes you do not want committed, unstage them with git reset first.
reset: Where the Changes Land in Each Mode
git reset moves the branch pointer back to a target commit; the three modes differ in where your changes end up. Tested by committing a.txt, then b.txt, then c.txt, and walking back from add-c one mode at a time:
| Mode | HEAD | Index | Working tree | Tested git status |
|---|---|---|---|---|
--soft HEAD~1 |
Back 1 | Kept (c.txt staged) | Kept | A c.txt |
--mixed HEAD~1 (default) |
Back 1 | Cleared | Kept (unstaged) | ?? b.txt ?? c.txt |
--hard HEAD~1 |
Back 1 | Cleared | Discarded (tracked files) | Clean |
One-line selection: soft to re-organize commits, mixed to re-edit, hard to truly discard.
A real edge case from the test: reset --hard does not delete untracked files. In the run above, b.txt had become untracked after mixed, and the subsequent hard reset left it on disk — untracked files are outside both the object database and the index. To clear them too, use git clean -fd (preview with git clean -nd first).
This yields a counter-intuitive safety note: hard reset does not guarantee a clean working tree — untracked and ignored files survive. Conversely, they also survive an accidental hard reset.
revert: The Only Safe Undo for Pushed Commits
git revert <hash> moves no pointers — it computes the inverse patch of the target commit and creates a new commit:
git revert --no-edit HEAD
# → [main 47d7e82] Revert "add-b"
git log --oneline | head -2
# 47d7e82 Revert "add-b"
# 6c3764a add-b
The test confirms: b.txt added by add-b is removed after the revert, and both commits remain in history — nothing was rewritten, and everyone who pulls lands in a consistent state. The cost is an extra "undo record" in history, which is exactly where auditability comes from.
The selection rule follows: pushed (especially on shared branches) → revert; local only → reset. Resetting a shared branch and force-pushing imposes your history on every collaborator — one of the most common team incidents (full comparison in Git Rebase vs Merge).
reflog: Recovering Commits You Thought Were Gone
Commits removed by git reset --hard are not immediately gone — git reflog records every HEAD movement (kept 90 days by default). The tested recovery flow:
git reset --hard HEAD~1 # oops: dropped add-b
git log --oneline | head -1 # → 6c3764a add-b (current HEAD)
git reflog | head -4
# 6c3764a HEAD@{0}: reset: moving to HEAD~1
# 47d7e82 HEAD@{1}: revert: Revert "add-b" ← the commit to recover is still here
git branch rescue 47d7e82 # one command to recover
git log rescue --oneline | head -1
# → 47d7e82 Revert "add-b" ✓
Three caveats verified along the way: reflog is local (not transferred by push, so teammates have no record of your movements); expired entries are garbage-collected; and uncommitted changes are not in it — they never entered the object database, which is the real meaning of "commit first, then experiment".
Reproducible Results
All outputs below come from a single throwaway repository (commands run verbatim):
① amend fixes the message:
echo v1 > a.txt && git add . && git commit -qm "initail commmit"
git commit --amend -qm "initial commit"
git log --oneline
# → ad04563 initial commit (typo fixed, hash changed)
② reset in three modes:
# walking back from add-c, one mode at a time
git reset --soft HEAD~1
# log: 6c3764a add-b status: A c.txt ← change is staged
git reset --mixed HEAD~1
# log: ad04563 init commit status: ?? b.txt ?? c.txt ← change is in the working tree
git reset --hard HEAD~1
# log: ad04563 init commit status: clean (tracked files)
# ⚠️ b.txt (untracked) still exists — hard does not clear untracked files
③ revert creates the inverse commit:
git revert --no-edit HEAD
# → [main 47d7e82] Revert "add-b"
git log --oneline | head -2
# 47d7e82 Revert "add-b" ← the inverse sits on top
# 6c3764a add-b ← the original is fully preserved
④ reflog recovery:
git reset --hard HEAD~1 # accidentally dropped the revert
git reflog | head -2
# 6c3764a HEAD@{0}: reset: moving to HEAD~1
# 47d7e82 HEAD@{1}: revert: Revert "add-b"
git branch rescue 47d7e82 # recovered ✓
Takeaways
| Principle | One line |
|---|---|
| Commit before experimenting | Uncommitted changes have no recovery path; once committed, reflog is your safety net |
| Private reset, shared revert | Draw the line at "is it pushed / does anyone collaborate", not at which command feels easier |
| amend for unpushed only | The hash changes; pushed commits get revert |
| untracked is the blind spot | reset --hard neither cleans nor saves it; git add new files early |
| force push with lease | --force-with-lease refuses when the remote moved on |
Command reference: Git commands and the git-memo tool; the full merge-strategy comparison in Git Rebase vs Merge.