Undoing Changes
Undoing changes in Git means choosing which snapshot to copy, which ref to move, or whether to record a new inverse commit. git restore, git reset, and git revert can produce the same file contents while leaving different Index state, branch history, object counts, and recovery pointers behind.
Think of the repository as a desk with three trays: the Working Tree, the Index, and the commit reached by HEAD -> main. Restore copies paper between trays. Reset moves the label on the filing cabinet, then may refill one or both trays. Revert files a new correction sheet. The page may read the same afterward; the audit trail does not.
How undoing changes works
git restore app.txt uses the Index as its default source and overwrites the Working Tree copy. It does not move main, rewrite the Index, or create an object. With --staged, the default source changes to the HEAD tree and the destination becomes the Index. Restore is a file-layer copy, not a history operation.
git reset <target> first resolves a target commit, records the old branch tip in ORIG_HEAD, and moves main to the target. The mode controls the remaining copies:
| Mode | Move main | Copy target to Index | Copy target to Working Tree | New commit |
|---|---|---|---|---|
--soft | yes | no | no | no |
--mixed | yes | yes | no | no |
--hard | yes | yes | yes | no |
That table is the whole reset family. --soft is useful when you want to move the branch but keep the old tip's snapshot staged. Mixed reset also replaces the Index. Hard reset goes one boundary farther and overwrites tracked Working Tree bytes. If those bytes were never staged or committed, ORIG_HEAD cannot rescue them; it stores a commit ID, not a filesystem backup.
git revert HEAD takes the opposite approach to shared history. It compares the selected commit with its parent, derives the inverse change, applies the result to the Index and Working Tree, writes a new commit, and advances main to that commit. The bad commit remains an ancestor. Revert does not move backward. It records that moving forward required a correction.
Undoing changes step by step
The default run starts with an unborn main and executes these exact commands:
edit app.txtwritesmode=safeonly to the Working Tree.git add app.txthashes those bytes into a blob and makes the Index entry point to it.git commit -m "base"writes the Index tree, writes root commit C1, then advancesmainto C1. Working Tree, Index, andHEADnow all containmode=safe.- A second edit changes only the Working Tree to
mode=draft. git restore app.txtresolves its default source as the Index and copiesmode=safeback into the Working Tree. The draft disappears, butmainand the Index do not move.- Another edit writes
mode=unsafeto the Working Tree. git add app.txtstores the unsafe blob and points the Index at it.git commit -m "bad-change"writes C2 with C1 as its parent, then advancesmainto C2. All three file layers now agree onmode=unsafe.git reset --soft HEAD^resolves C1, writes C2 toORIG_HEAD, and movesmainback to C1. The Index and Working Tree remainmode=unsafe, so the unsafe snapshot is staged relative to the newHEAD.git reset --soft ORIG_HEADresolves the saved C2 before replacingORIG_HEADwith C1. It movesmainforward to C2 and still leaves both file layers alone. This ordering matters; resolvingORIG_HEADafter overwriting it would target the wrong commit.git revert HEADverifies that Index and Working Tree match C2, derivesmode=unsafe -> mode=safefrom C2 and C1, and applies that result. The existing C1 tree can be reused, so the operation adds one commit object C3, not a new blob or tree. Finally,mainadvances to C3. C2 remains C3's parent and stays reachable.
The final file says mode=safe, exactly as it did at C1. A hard reset to C1 could produce that same text with main pointing backward and only two stored commits. Revert instead leaves main at a third commit whose parent is the bad one. File content is evidence about one snapshot, not proof of how the repository got there.
What each undo command writes
The useful question is not "does the file look fixed?" Ask which durable names and snapshots changed.
| Command family | Working Tree | Index | main | Object database |
|---|---|---|---|---|
restore app.txt | copied from Index | unchanged | unchanged | unchanged |
restore --staged app.txt | unchanged | copied from HEAD | unchanged | unchanged |
reset --soft | unchanged | unchanged | moved | unchanged |
reset --mixed | unchanged | copied from target | moved | unchanged |
reset --hard | copied from target | copied from target | moved | unchanged |
revert HEAD | inverse result | inverse result | advanced | one new commit in this trace |
In the one-file engine, hashing or copying file bytes costs O(B) for B bytes. Resolving HEAD^ is O(1) because commits have one parent and the target is adjacent. Classifying which stored commits remain reachable from main costs O(V + E). Those are costs of this browser model, not performance promises about Git's index, packfiles, revision walker, or filesystem.
A note on simplification: one file, linear history
This lesson uses one local repository, an attached HEAD -> main, one regular tracked file, and single-parent commits. It implements whole-file restore, --soft/--mixed/--hard reset, and a clean git revert HEAD with deterministic identities and real Git-format SHA-1 object IDs.
It omits pathspec sets, patch mode, deletes, renames, file modes, untracked-file obstruction, detached HEAD, multiple branches, merge commits, revert -m, arbitrary older-commit conflicts, the revert sequencer, hooks, signatures, remotes, reflog expiry, garbage collection, and editor or filesystem recovery. Consult the official git restore, git reset, and git revert documentation before using a destructive command on real work.
Edge cases
- An unborn
HEADhas no tree, sogit restore --staged app.txthas no default source. - A root commit has no first parent.
HEAD^therefore fails instead of resolving to an empty tree. ORIG_HEADis unset until a successful reset in this model. An unknown or ambiguous object-ID prefix also fails without mutation.- Restore becomes a no-op when the destination already matches its source. No ref or object changes to make the command look busier.
- Revert refuses when either the Index or Working Tree differs from
HEAD. This scoped guard prevents a fabricated conflict workflow. - Hard reset can discard tracked Working Tree bytes that Git has never stored as an object. A commit recovery pointer is not a backup of uncommitted edits.
Common mistakes
- Saying restore "moves a file." It copies a snapshot into a destination; the source remains where it was.
- Saying reset deletes commits. Reset moves a ref. A displaced commit may still be named by
ORIG_HEADor another ref, and object cleanup is a separate concern. - Treating
ORIG_HEADas the reflog. It is one pseudo-ref containing one commit ID. The reflog is a separate history of ref updates and is outside this lesson. - Saying revert stores a patch object. Commits store trees and parent links. Git computes an inverse change, then records the resulting snapshot in a new commit.
- Running
git reset --hardbecause the target commit is recoverable. The target may be recoverable while overwritten, uncommitted Working Tree bytes are not.
Keep exploring
A few lessons that share the same technique or lead into the next idea.
Found a mistake or something unclear? Leave feedback.