Undoing Things in Git
Every git horror story is really a story about not knowing this page. The undo commands look scattered and folkloric — reset with three flags, revert, restore, amend, reflog — but they’re all one system: git’s database is append-only, so “undo” always means moving pointers, and the reflog is the journal of every pointer move. Once you see that, undo becomes boring. This page makes it boring.
Sanitized — no real hosts, paths, or project names. Continues the sensor-logger example. Assumes the three-trees model from Git-from-First-Principles — HEAD ← index ← working tree — this page is that diagram, applied.
The safety model first
From Git-from-First-Principles: objects are immutable and append-only; the only mutable things are the pointer files. So:
- No undo command deletes commits. Reset, rebase,
branch -D— all of them move pointers. A commit no pointer can reach becomes invisible, not gone, and sticks around for ~90 days before garbage collection. - The only thing git can actually destroy is work you never gave it — uncommitted changes in the working tree, and untracked files. Every genuinely dangerous flag on this page is dangerous for exactly that reason and no other.
Which yields the one-line safety policy: commit early, commit ugly. A bad commit is trivially fixable (this whole page); lost uncommitted work is the only unfixable thing. wip commits on a private branch cost nothing — rebase or amend will tidy them later.
git reset: moving HEAD’s branch, then optionally more
reset moves the branch HEAD points at to a different commit. Its three modes are just “how many of the three trees should follow the pointer?”:
| Mode | HEAD’s branch | Index | Working tree | Meaning |
|---|---|---|---|---|
--soft | moves | untouched | untouched | Un-commit: changes stay staged |
--mixed (default) | moves | reset to match | untouched | Un-commit and un-stage: changes stay in your files |
--hard | moves | reset to match | reset to match | Discard: everything matches the target commit |
The three most common uses, in the same order:
git reset --soft HEAD~1 # "un-commit" — redo the last commit's message or contents
git reset HEAD~2 # squash prep: last 2 commits' changes back in working tree
git reset --hard origin/main # abandon local state; match the remote exactly
--soft and --mixed are always safe — they cannot touch your files. --hard is the flag that earns the warnings, and only because of the safety model above: the commits it moves away from are recoverable via the reflog, but any uncommitted changes it overwrites never existed in the database and are gone for real. Look at git status before every --hard; if it isn’t clean, you’re about to destroy the one thing git can’t restore.
File-level cousins (newer, clearer names for what checkout used to overload):
git restore sensor.c # working tree file ← index (discard unstaged edits)
git restore --staged sensor.c # index entry ← HEAD (unstage; file untouched)
git restore --source HEAD~3 sensor.c # grab one file's old version, nothing else moves
git revert: undo as new history
reset rewrites where a branch points — which, per the golden rule in Merging-Rebasing-and-Reading-History, you must not do to commits others already have. For shared history there’s revert:
git revert 9c4e2d1
This doesn’t remove anything. It creates a new commit whose changes are the exact inverse of the target commit. History moves forward only; everyone else just pulls the antidote like any other commit.
The decision rule is one question: has the commit been pushed somewhere shared? Private → reset (rewrite freely). Shared → revert (append the inverse). This is the same rule as rebase, because it is the same underlying act — rewriting refs that others’ repos point into.
git commit --amend: the small rewrite
git commit --amend # re-open the last commit (message and/or staged changes)
Mechanically this is a mini-rebase: it builds a replacement commit (new hash — content, parent, and metadata are hashed, so any change means a new ID) and moves the branch to it. Same rule applies: amend freely before pushing, not after.
The reflog: the journal
Everything above “moves pointers.” The reflog is the per-ref journal of every move — commit, reset, rebase, merge, checkout — kept locally for ~90 days:
$ git reflog
1f8e3c2 HEAD@{0}: reset: moving to HEAD~2
9c4e2d1 HEAD@{1}: commit: fix: clamp negative sensor readings
5a81f03 HEAD@{2}: commit: add range test
Filesystem analogy: git is a journaling filesystem for history. The object database is the data; refs are the mount table; the reflog is the journal you replay after a mistake.
So the “disaster” recovery procedure is two commands, always the same two:
$ git reflog # find the hash from before the mistake
$ git reset --hard 9c4e2d1 # point the branch back at it
That pair recovers from a wrong reset --hard (the committed part), a botched rebase (the originals are in the reflog), a deleted branch (git branch rescued 9c4e2d1 re-attaches a name), an amend you regret — all of them. The reflog is local-only and expires, so it’s a safety net, not an archive; but inside its window, any committed state can be re-pointed-at.
The lookup table
| “I want to…” | Command | Safe? |
|---|---|---|
| Fix the last commit’s message | git commit --amend | If unpushed |
| Add a forgotten file to the last commit | git add f; git commit --amend --no-edit | If unpushed |
| Un-commit but keep changes staged | git reset --soft HEAD~1 | Always |
| Un-stage a file | git restore --staged f | Always |
| Discard unstaged edits to a file | git restore f | Destroys uncommitted work |
| Throw away all local commits + changes, match remote | git reset --hard origin/main | Uncommitted work destroyed |
| Undo a pushed commit | git revert <hash> | Always — appends, never rewrites |
| Recover from any pointer mistake | git reflog → git reset --hard <hash> | The net itself |
| Back out of a half-done merge/rebase | git merge --abort / git rebase --abort | Always |
Where this leaves the series: you now have the database model (Git-from-First-Principles), remotes as caches of someone else’s pointers (Remotes-and-Tracking-Branches), control over what git watches at all (Ignoring-Files-in-Git), the collaboration loop (Pull-Commit-Push-on-a-Shared-Repo, Using-git-stash-Safely), combining work (Merging-Rebasing-and-Reading-History), and the safety net (this page). The capstone is Git-Worktrees-and-Parallel-Agents — running several checkouts of one repo at once, which is how parallel coding agents work a repo without colliding.