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:

ModeMove mainCopy target to IndexCopy target to Working TreeNew commit
--softyesnonono
--mixedyesyesnono
--hardyesyesyesno

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:

  1. edit app.txt writes mode=safe only to the Working Tree.
  2. git add app.txt hashes those bytes into a blob and makes the Index entry point to it.
  3. git commit -m "base" writes the Index tree, writes root commit C1, then advances main to C1. Working Tree, Index, and HEAD now all contain mode=safe.
  4. A second edit changes only the Working Tree to mode=draft.
  5. git restore app.txt resolves its default source as the Index and copies mode=safe back into the Working Tree. The draft disappears, but main and the Index do not move.
  6. Another edit writes mode=unsafe to the Working Tree.
  7. git add app.txt stores the unsafe blob and points the Index at it.
  8. git commit -m "bad-change" writes C2 with C1 as its parent, then advances main to C2. All three file layers now agree on mode=unsafe.
  9. git reset --soft HEAD^ resolves C1, writes C2 to ORIG_HEAD, and moves main back to C1. The Index and Working Tree remain mode=unsafe, so the unsafe snapshot is staged relative to the new HEAD.
  10. git reset --soft ORIG_HEAD resolves the saved C2 before replacing ORIG_HEAD with C1. It moves main forward to C2 and still leaves both file layers alone. This ordering matters; resolving ORIG_HEAD after overwriting it would target the wrong commit.
  11. git revert HEAD verifies that Index and Working Tree match C2, derives mode=unsafe -> mode=safe from 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, main advances 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 familyWorking TreeIndexmainObject database
restore app.txtcopied from Indexunchangedunchangedunchanged
restore --staged app.txtunchangedcopied from HEADunchangedunchanged
reset --softunchangedunchangedmovedunchanged
reset --mixedunchangedcopied from targetmovedunchanged
reset --hardcopied from targetcopied from targetmovedunchanged
revert HEADinverse resultinverse resultadvancedone 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 HEAD has no tree, so git restore --staged app.txt has no default source.
  • A root commit has no first parent. HEAD^ therefore fails instead of resolving to an empty tree.
  • ORIG_HEAD is 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_HEAD or another ref, and object cleanup is a separate concern.
  • Treating ORIG_HEAD as 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 --hard because the target commit is recoverable. The target may be recoverable while overwritten, uncommitted Working Tree bytes are not.