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?”:

ModeHEAD’s branchIndexWorking treeMeaning
--softmovesuntoucheduntouchedUn-commit: changes stay staged
--mixed (default)movesreset to matchuntouchedUn-commit and un-stage: changes stay in your files
--hardmovesreset to matchreset to matchDiscard: 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…”CommandSafe?
Fix the last commit’s messagegit commit --amendIf unpushed
Add a forgotten file to the last commitgit add f; git commit --amend --no-editIf unpushed
Un-commit but keep changes stagedgit reset --soft HEAD~1Always
Un-stage a filegit restore --staged fAlways
Discard unstaged edits to a filegit restore fDestroys uncommitted work
Throw away all local commits + changes, match remotegit reset --hard origin/mainUncommitted work destroyed
Undo a pushed commitgit revert <hash>Always — appends, never rewrites
Recover from any pointer mistakegit refloggit reset --hard <hash>The net itself
Back out of a half-done merge/rebasegit merge --abort / git rebase --abortAlways

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.