GadaaLabs
Git Fundamentals — Version Control for Every Developer
Lesson 4

Undoing Changes — reset, revert, restore

22 min

The Safety Net Mindset

One of the most liberating things about Git is that mistakes are almost always recoverable. This is not an accident — Git was designed with recovery in mind. The object database is append-only: objects are added but never removed (until explicit garbage collection). Even when you think you have lost a commit, Git almost certainly still has it.

But "almost always recoverable" is not the same as "always recoverable." There are a handful of operations that genuinely destroy data. This lesson teaches you the full spectrum: from completely safe undo operations to ones that require caution. By the end, you will know which tool to reach for in any situation, and you will understand the risks of each.


The Undo Decision Tree

Before diving into individual commands, here is a mental model for choosing the right undo operation:

Where is the change I want to undo?

├── Working directory (not staged)
│   └── git restore <file>               (discard working dir changes)

├── Staging area (staged, not committed)
│   └── git restore --staged <file>      (unstage, keep working dir changes)

├── Last commit (on local branch, not pushed)
│   ├── git commit --amend               (replace the last commit)
│   ├── git reset --soft HEAD~1          (undo commit, keep staged)
│   ├── git reset --mixed HEAD~1         (undo commit, keep working dir)
│   └── git reset --hard HEAD~1          (undo commit, discard everything)

├── Older commits (may be pushed/shared)
│   └── git revert <hash>                (safe: creates new undo commit)

└── Untracked files
    └── git clean -fd                    (remove untracked files/dirs)

git restore — Discarding Changes in the Working Directory

git restore is the safe, focused command for discarding changes. It was introduced in Git 2.23 alongside git switch to replace the confusing overloaded behaviors of git checkout.

Discard Unstaged Changes in a File

bash
# You modified taskr.sh and want to throw away those changes
git restore taskr.sh

This replaces the working directory version of taskr.sh with the version from the last commit (HEAD). Your changes are permanently discarded — they do not go to the staging area or anywhere else. There is no undo for this operation.

bash
# Discard changes in multiple specific files
git restore taskr.sh README.md

# Discard all changes in the current directory
git restore .

# Discard changes in a subdirectory
git restore src/

Restore from a Specific Commit

By default, git restore uses HEAD (the last commit) as the source. You can restore from any commit:

bash
# Restore taskr.sh to its state 3 commits ago
git restore --source HEAD~3 taskr.sh

# Restore from a specific commit hash
git restore --source 4f2a8b1 taskr.sh

# Restore from a branch
git restore --source main taskr.sh

This is useful for recovering a specific file version without affecting the rest of your working directory.


git restore --staged — Unstaging Files

If you have staged changes that you do not want in the next commit, unstage them:

bash
git add taskr.sh README.md    # Staged both files

# Oops, README changes are not ready
git restore --staged README.md

This removes README.md from the staging area but leaves the changes in the working directory. Run git status and you will see:

Changes to be committed:
        modified:   taskr.sh

Changes not staged for commit:
        modified:   README.md

The staging area has been cleaned up; the working directory still has your README changes.

bash
# Unstage everything
git restore --staged .

git reset — Rewinding History

git reset is more powerful and more dangerous than git restore. It moves the HEAD pointer (and the current branch pointer) to a different commit. Depending on the mode, it also affects the staging area and working directory.

There are three modes: --soft, --mixed, and --hard.

Understanding the Three Modes

Think of these three modes as controlling how far the reset "cascades down" through the three areas.

Repository    Staging Area    Working Directory
git reset --soft     RESET          unchanged         unchanged
git reset --mixed    RESET          RESET             unchanged
git reset --hard     RESET          RESET             RESET

git reset --soft — Undo the Commit, Keep Everything Staged

bash
git log --oneline
# c3d4e5f feat: add list-by-priority command
# 2a3b4c5 feat: add priority levels to tasks
# 9a1b2c3 chore: add .gitignore

# Undo the last commit but keep its changes staged
git reset --soft HEAD~1

After this:

  • HEAD and the branch now point to 2a3b4c5
  • The changes from c3d4e5f are in the staging area (as if you had staged them but not committed)
  • The working directory is unchanged

Use --soft when: you want to rewrite the commit message, combine this commit with the next one, or split this commit into multiple smaller commits.

bash
git log --oneline
# 2a3b4c5 (HEAD -> main) feat: add priority levels to tasks
# 9a1b2c3 chore: add .gitignore

git status
# Changes to be committed:
#   modified: taskr.sh   (the changes from the undone commit)

git reset --mixed — Undo the Commit and Unstage, Keep Working Directory

This is the default mode when you run git reset without a flag.

bash
git reset --mixed HEAD~1
# or equivalently:
git reset HEAD~1

After this:

  • HEAD and the branch move back one commit
  • The changes from the undone commit are in the working directory (unstaged)
  • The staging area is cleared

Use --mixed when: you want to recommit differently but need to re-review and re-stage the changes. It is the safest reset mode because you never lose any work — everything is still in your working directory.

git reset --hard — Undo Everything

bash
git reset --hard HEAD~1

After this:

  • HEAD and the branch move back one commit
  • The staging area is cleared
  • The working directory is reset to the commit's state

The changes from the undone commit are permanently discarded. They are gone from your working directory. This is the most dangerous Git command for data loss.

Use --hard when: you are absolutely certain you want to discard all changes and return to a specific earlier state.

bash
# Common use cases:

# Throw away everything since the last commit
git reset --hard HEAD

# Go back 3 commits, discarding all changes
git reset --hard HEAD~3

# Reset to a specific commit
git reset --hard 4f2a8b1

# Reset a branch to match origin (discard all local unpushed commits)
git reset --hard origin/main

HEAD~N Notation

HEAD~1 means "the commit one before HEAD." More generally:

bash
HEAD~1    # Parent of HEAD (one commit back)
HEAD~2    # Grandparent (two commits back)
HEAD~3    # Three commits back
HEAD~     # Same as HEAD~1

HEAD^     # Also means parent (same as HEAD~1 for most commits)
HEAD^2    # Second parent of a merge commit (the merged branch)

git revert — Safe Undo for Shared History

git reset rewrites history — it changes which commit HEAD points to. This is safe on local branches that have not been shared. But once you have pushed commits to a shared remote, resetting and force-pushing is dangerous because it conflicts with your teammates' local copies.

git revert is the safe alternative. Instead of moving the branch pointer back, it creates a new commit that applies the inverse of the specified commit. The original commit remains in history; a new "undo" commit is added on top.

bash
git log --oneline
# c3d4e5f feat: add list-by-priority command
# 2a3b4c5 feat: add priority levels to tasks
# 9a1b2c3 chore: add .gitignore

# Revert the priority levels commit (leave list-by-priority intact)
git revert 2a3b4c5

Git opens your editor with a default commit message:

Revert "feat: add priority levels to tasks"

This reverts commit 2a3b4c5d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9.

Save and close. Git creates the revert commit:

bash
git log --oneline
# f6a7b8c Revert "feat: add priority levels to tasks"
# c3d4e5f feat: add list-by-priority command
# 2a3b4c5 feat: add priority levels to tasks
# 9a1b2c3 chore: add .gitignore

The history is intact. The revert commit cleanly undoes the changes of 2a3b4c5. This is push-safe: your teammates can pull this without any conflicts in history.

bash
# Revert multiple commits
git revert HEAD~3..HEAD   # Revert last 3 commits

# Revert without opening an editor (use auto-generated message)
git revert --no-edit 2a3b4c5

# Stage the revert but do not commit yet (so you can edit the message)
git revert --no-commit 2a3b4c5

reset vs revert: When to Use Which

| Situation | Use | |-----------|-----| | Undo on a local branch, not yet pushed | git reset (any mode) | | Undo a specific commit in shared history | git revert | | Undo multiple commits in shared history | git revert (one per commit, or range) | | Temporarily go back to inspect old code | git checkout <hash> (detached HEAD) |


git clean — Removing Untracked Files

git restore and git reset operate on tracked files. Untracked files are not affected by them. To remove untracked files, use git clean.

bash
# Show what would be deleted (dry run — always do this first!)
git clean -n
git clean --dry-run

# Delete untracked files (not directories)
git clean -f

# Delete untracked files AND untracked directories
git clean -fd

# Delete files ignored by .gitignore as well (caution!)
git clean -fX   # Only ignored files
git clean -fx   # Both ignored and untracked files

# Interactive mode — confirm each file
git clean -i

Always run git clean -n before git clean -f. The dry run shows exactly what will be deleted. Unlike most Git operations, git clean on untracked files is not recoverable — these files were never in Git, so they cannot be restored from Git's history.


git stash — Save Work in Progress

git stash is the "pause button" for your working directory. It lets you save your current state — all modified tracked files and staged changes — set the working directory back to a clean state (matching HEAD), and then restore your changes later.

The classic use case: you are halfway through a feature when an urgent bug report comes in. You are not ready to commit the feature work. You stash it, fix the bug, commit the fix, then pop the stash to resume where you left off.

Basic Stash Operations

bash
# Stash everything (tracked files only by default)
git stash

# Stash with a descriptive name
git stash push -m "WIP: priority feature - half done"

# Include untracked files in the stash
git stash push -u

# Include untracked AND ignored files
git stash push -a

# List all stashes
git stash list

Output of git stash list:

stash@{0}: On feature/add-priority: WIP: priority feature - half done
stash@{1}: On main: quick experiment with colors

Stashes are numbered stash@{0} (most recent) through stash@{N} (oldest). The {0} stash is always the most recent.

Applying Stashes

bash
# Apply the most recent stash (keeps the stash in the list)
git stash apply

# Apply a specific stash by index
git stash apply stash@{2}

# Apply the most recent stash AND remove it from the list
git stash pop

# Pop a specific stash
git stash pop stash@{1}

apply leaves the stash in the list (useful if you want to apply it to multiple branches). pop removes it after applying.

Inspecting Stashes

bash
# See what is in a stash
git stash show stash@{0}

# See the full diff of a stash
git stash show -p stash@{0}

# Show the most recent stash diff
git stash show -p

Removing Stashes

bash
# Remove a specific stash
git stash drop stash@{1}

# Remove all stashes
git stash clear

Creating a Branch from a Stash

If significant time has passed since you stashed and the base branch has changed considerably, applying the stash to your current branch might cause conflicts. An alternative: create a new branch from the commit where you stashed, apply the stash there, and work from that branch:

bash
git stash branch feature/priority-work stash@{0}

This creates and switches to a new branch based on the commit where you ran git stash, and applies the stash on top. If there are conflicts, they will be much smaller.

Stash and Staged Files

By default, git stash stashes everything together, including staged files. To stash only unstaged changes while leaving the index (staging area) intact:

bash
git stash push --keep-index

git reflog — The Ultimate Safety Net

git reflog is the command that has saved countless developers from disaster. It records every time HEAD moves — every checkout, commit, reset, merge, rebase — for the last 90 days (by default).

Even after a git reset --hard that seemed to delete commits, those commits still exist in Git's object database. git reflog lets you find them.

Using reflog to Recover Lost Commits

Scenario: you just ran git reset --hard HEAD~3 and immediately realized those three commits were important.

bash
git reflog
9a1b2c3 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~3
c3d4e5f HEAD@{1}: commit: feat: add list-by-priority command
2a3b4c5 HEAD@{2}: commit: feat: add priority levels to tasks
b3c4d5e HEAD@{3}: commit: fix: improve usage message formatting
9a1b2c3 HEAD@{4}: commit: chore: add .gitignore
4f2a8b1 HEAD@{5}: commit: Initial commit: add taskr.sh and README

The reflog shows you were on c3d4e5f (with the commits intact) before the reset. To recover:

bash
# Option 1: Reset back to where you were
git reset --hard c3d4e5f

# Option 2: Create a branch at the lost commit (safer)
git branch recovered-work c3d4e5f

# Option 3: Cherry-pick the lost commits onto current branch
git cherry-pick 2a3b4c5
git cherry-pick c3d4e5f

Reading the reflog

Each entry in the reflog has the format:

<short-hash> HEAD@{n}: <action>: <description>
  • HEAD@{0} is the current state
  • HEAD@{1} is one step back in HEAD history
  • Actions include: commit, checkout, reset, merge, rebase, pull

You can also view the reflog for a specific branch:

bash
git reflog show main
git reflog show feature/add-priority

Reflog is Local

The reflog is stored in .git/logs/ and is specific to your local repository. It is not transferred when you push or pull. If you clone a repository or lose your .git/ directory entirely, reflog cannot save you. This is why regular backups and pushes to a remote are still important.


Practical Reference: Which Command to Use?

Here is a consolidated reference. Bookmark this.

bash
# WORKING DIRECTORY

# Discard changes to a specific file (WARNING: permanent)
git restore <file>

# Discard all working directory changes (WARNING: permanent)
git restore .


# STAGING AREA

# Unstage a specific file (keep working dir changes)
git restore --staged <file>

# Unstage everything
git restore --staged .


# COMMITS (LOCAL, NOT PUSHED)

# Undo last commit, keep changes staged
git reset --soft HEAD~1

# Undo last commit, keep changes in working dir (unstaged)
git reset --mixed HEAD~1

# Undo last commit and discard all changes (WARNING: permanent)
git reset --hard HEAD~1

# Fix the last commit's message or add a forgotten file
git commit --amend


# COMMITS (SHARED / PUSHED)

# Safely undo a specific commit by creating an inverse commit
git revert <commit-hash>


# UNTRACKED FILES

# Preview what would be deleted
git clean -n

# Delete untracked files
git clean -f

# Delete untracked files and directories
git clean -fd


# WORK IN PROGRESS

# Stash current changes
git stash push -m "description"

# List stashes
git stash list

# Restore most recent stash
git stash pop


# RECOVERY

# View history of HEAD movements
git reflog

# Recover lost commits
git reset --hard HEAD@{n}
# or
git branch recovery-branch <lost-commit-hash>

Practical Exercises

Exercise 1: Practice git restore

bash
cd ~/taskr

# Modify taskr.sh (make any change)
# Then immediately discard the change
git restore taskr.sh

# Verify the change is gone
git diff taskr.sh

# Now stage a change without committing
git add taskr.sh

# Unstage it
git restore --staged taskr.sh

# Verify it is unstaged but still in working dir
git status

Exercise 2: Practice the Three reset Modes

bash
# Make 3 test commits
echo "test 1" >> test.txt && git add test.txt && git commit -m "test: commit 1"
echo "test 2" >> test.txt && git add test.txt && git commit -m "test: commit 2"
echo "test 3" >> test.txt && git add test.txt && git commit -m "test: commit 3"

# Try --soft reset
git reset --soft HEAD~1
git status   # Changes should be staged

# Re-commit them
git commit -m "test: re-commit 3"

# Try --mixed reset
git reset --mixed HEAD~1
git status   # Changes should be in working dir, unstaged

# Re-stage and re-commit
git add test.txt && git commit -m "test: re-commit 3 again"

# Try --hard reset (DESTROYS changes)
git reset --hard HEAD~1
git status   # Clean working directory
git log --oneline   # test 3 is gone

# Clean up
rm test.txt

Exercise 3: reflog Recovery

bash
# Make 2 important commits
echo "important 1" >> important.txt && git add important.txt && git commit -m "feat: important change 1"
echo "important 2" >> important.txt && git add important.txt && git commit -m "feat: important change 2"

# Accidentally reset --hard
git reset --hard HEAD~2

# Recover using reflog
git reflog
# Find the commit hashes for "important change 2"
git reset --hard HEAD@{2}  # or use the specific hash

# Verify recovery
git log --oneline
cat important.txt

Exercise 4: Master git stash

bash
# Start making changes to taskr.sh
# (Add some new functionality, partway through)

# An urgent bug comes in — stash your work
git stash push -m "WIP: new feature in progress"

# Verify working dir is clean
git status

# Create a hotfix branch, fix the bug, commit, merge back
git switch -c hotfix/urgent-fix
# (make a small change)
git add . && git commit -m "fix: urgent production fix"
git switch main
git merge hotfix/urgent-fix
git branch -d hotfix/urgent-fix

# Now restore your WIP
git stash pop

# Verify your changes are back
git status
git diff

Challenge: Build a Recovery Scenario

  1. Make 5 commits with meaningful changes
  2. Run git reset --hard HEAD~3 — "accidentally" delete 3 commits
  3. Using only git reflog, find the lost commits
  4. Recover them completely — all 5 commits should be in your history

Summary

  • git restore <file> discards unstaged working directory changes permanently. Use with care.
  • git restore --staged <file> removes a file from the staging area while keeping the working directory changes.
  • git reset moves the branch pointer. --soft keeps staged changes intact; --mixed (default) unstages but keeps working dir; --hard discards everything including working dir changes.
  • git revert is the safe option for undoing commits that have been shared/pushed — it creates a new inverse commit rather than rewriting history.
  • git clean removes untracked files. Always run -n (dry run) before -f (force delete).
  • git stash saves your work in progress to a temporary stack, cleans your working directory, and lets you restore later with git stash pop.
  • git reflog records every movement of HEAD for 90 days and can recover commits even after a hard reset.

The next lesson covers remote repositories — connecting your local repository to GitHub or another remote, pushing and pulling changes, and understanding how tracking branches work.