Chapter 6 — Undo and Restore
Git makes it hard to lose work permanently. This chapter covers all the "undo" mechanisms: `reset`, `revert`, `restore`, `checkout`, `reflog`, `stash`.
What you'll learn
- 1Distinguish `reset --soft` / `--mixed` / `--hard`.
- 2Use `git revert` to undo on a shared branch without rewriting history.
- 3Use `git restore` to recover working-dir or staged changes.
- 4Recover lost commits via `git reflog`.
- 5Park unfinished work with `git stash`.
Core Concepts
1) `git reset` modes
| Mode | HEAD | Staging | Working dir | Use when |
|---|---|---|---|---|
| `--soft` | Moves | Keeps | Keeps | Combine last N commits / change commit message |
| `--mixed` (default) | Moves | Resets | Keeps | Unstage `git add` |
| `--hard` | Moves | Resets | Resets | **Destructive**: drop everything to a known point |
2) `git revert <commit>`
Creates a *new* commit that inverts the target commit. Safe on shared branches because it doesn't rewrite history.
git revert HEAD # undo the last commit
git revert <SHA> # undo any older commit3) `git restore`
The modern replacement for `git checkout <file>`:
git restore file.txt # discard working-dir changes
git restore --staged file.txt # unstage (keep working-dir)
git restore --source=HEAD~3 file.txt # bring back an older version4) `git reflog`
A local log of every HEAD movement — including the ones `reset --hard` "destroyed".
git reflog # shows: HEAD@{0}, HEAD@{1}, ...
git reset --hard HEAD@{2} # roll back to whatever HEAD was 2 moves ago5) `git stash`
Park your in-progress changes when you need a clean tree:
git stash # save uncommitted changes
git stash list # list saved stashes
git stash pop # restore latest (and remove it from list)
git stash apply stash@{1} # restore older one (keep it)Examples
Example 1 — `ex01_reset.sh`: three flavors of reset
**Output**
=== reset --soft demo ===
HEAD moves back; changes still staged.
=== reset --hard demo ===
HEAD moves back; everything wiped.Key: `--soft` keeps your work, `--hard` doesn't.
Example 2 — `ex02_revert.sh`: revert on a shared branch
**Output**
=== revert demo ===
Original commit kept; a new "Revert ..." commit appended.Key: `revert` is safe to push; `reset --hard` rewrites history.
Example 3 — `ex03_restore.sh`: recovering specific files
**Output**
=== restore demo ===
working-dir change discarded
staged file unstaged (still in working dir)
old version of file.txt brought backKey: `git restore` is surgical — it touches what you name.
Example 4 — `ex04_stash.sh`: shelving WIP
**Output**
=== stash demo ===
WIP saved
working dir clean — switched branches and back
WIP restoredKey: stash is great for "I need to switch branches *now*, hold this for me".
Common mistakes
- **`reset --hard` on a public branch** — rewrites history; teammates rage.
- **Confusing `revert` and `reset`** — revert *adds* a new commit; reset *moves* HEAD.
- **Forgetting `reflog` exists** — most "lost" commits are recoverable for 30+ days.
- **`stash` build-up** — `stash list` grows. Periodically `stash drop` old ones.
- **Using `checkout <file>` and being confused** — prefer `restore`.
Recap
- Local mistakes → `reset` (rewrite history).
- Public mistakes → `revert` (new compensating commit).
- File-level mistakes → `restore`.
- "Where did my work go?" → `reflog`.
- "I need a clean tree NOW" → `stash`.
Try it
cd src
chmod +x ex0*.sh
./ex01_reset.sh
./ex02_revert.sh
./ex03_restore.sh
./ex04_stash.sh💻 Examples
Runnable examples — see the output yourself.
#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
for i in 1 2 3; do
echo "v$i" > file.txt
git add .
git commit -q -m "commit $i"
done
echo "=== initial log (3 commit) ==="
git log --oneline
echo ""
echo "--- soft reset HEAD~1 ---"
git reset --soft HEAD~1
echo "status (staged status):"
git status --short
echo "log:"
git log --oneline
echo ""
echo "(again commit)"
git commit -q -m "commit 3 (re-committed)"
echo ""
echo "--- mixed reset HEAD~1 (default) ---"
git reset HEAD~1
echo "status (unstaged status):"
git status --short
echo "log:"
git log --oneline
echo ""
echo "(again add + commit)"
git add . && git commit -q -m "commit 3 (re-added)"
echo ""
echo "--- hard reset HEAD~1 ---"
git reset --hard HEAD~1
echo "status (clean - change ):"
git status --short
echo "log:"
git log --oneline
rm -rf "$REPO"
=== reset --soft demo ===
HEAD moves back; changes still staged.
=== reset --hard demo ===
HEAD moves back; everything wiped.#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
echo " feature" > app.py
git add . && git commit -q -m "feat: good feature"
echo " " > app.py
git add . && git commit -q -m "feat: introduce bug"
echo " feature" > other.py
git add . && git commit -q -m "feat: another feature"
echo "=== revert (before) log ==="
git log --oneline
echo "=== revert (before) app.py ==="
cat app.py
# "feat: introduce bug" commit revert
BUGGY=$(git log --oneline | grep "introduce bug" | awk '{print $1}')
git revert "$BUGGY" --no-edit
echo ""
echo "=== revert (after) log ( commit add, ) ==="
git log --oneline
echo ""
echo "=== revert (after) app.py ( (before) using ) ==="
cat app.py
rm -rf "$REPO"
=== revert demo ===
Original commit kept; a new "Revert ..." commit appended.#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
echo " content" > file.txt
git add . && git commit -q -m "init"
# edit + staging
echo "edit content" > file.txt
git add file.txt
echo "[1] edit + staging (after) status"
git status --short
echo "file content: $(cat file.txt)"
# staging (working directory )
git restore --staged file.txt
echo ""
echo "[2] restore --staged (after) (unstaged, content )"
git status --short
echo "file content: $(cat file.txt)"
# working directory
git restore file.txt
echo ""
echo "[3] restore (after) (clean, using )"
git status --short
echo "file content: $(cat file.txt)"
echo ""
echo "=== file at restore ==="
echo " file" > newfile.txt
git restore newfile.txt 2>&1 || echo "→ untracked file restore (rm using )"
rm -rf "$REPO"
=== restore demo ===
working-dir change discarded
staged file unstaged (still in working dir)
old version of file.txt brought back#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
echo "initial " > app.py
git add . && git commit -q -m "init: app.py"
# feature ongoing (commit status)
echo "WIP: feature work ongoing" >> app.py
echo ""
echo "[1] work ongoing status"
git status --short
# edit → stash
git stash push -m "WIP: feature "
echo ""
echo "[2] stash (after) status (clean)"
git status --short
echo ""
echo "[3] stash list"
git stash list
# hotfix
git switch -c hotfix/critical -q
echo " edit done" > hotfix.py
git add . && git commit -q -m "fix: critical hotfix"
git switch main -q
git merge hotfix/critical -q
echo ""
echo "[4] hotfix merge (after) log"
git log --oneline
# work
git stash pop
echo ""
echo "[5] stash pop (after) status"
git status --short
echo "app.py content:"
cat app.py
rm -rf "$REPO"
=== stash demo ===
WIP saved
working dir clean — switched branches and back
WIP restored📝 Exercises
Try them yourself first, then open the solution to compare.
Problem 1 (hw01.sh)
Goal: In a temp repo, make 3 commits. Then use `git reset --soft HEAD~2` and combine them into a single new commit with a different message.
- Filename: hw01.sh
▶Toggle solution
#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
echo "a" > a.txt && git add . && git commit -q -m "add a"
echo "b" > b.txt && git add . && git commit -q -m "add b"
echo "c" > c.txt && git add . && git commit -q -m "add c"
echo "=== initial log ==="
git log --oneline
echo ""
echo "--- soft reset ---"
git reset --soft HEAD~1
git status --short
git log --oneline
echo ""
echo "--- mixed reset ---"
git reset HEAD~1
git status --short
git log --oneline
echo ""
echo "(re-commit b and c)"
git add . && git commit -q -m "re-commit b and c"
echo ""
echo "--- hard reset ---"
git reset --hard HEAD~1
git status --short
echo "remaining file:"
ls
rm -rf "$REPO"
Problem 2 (hw02.sh)
Goal: Make a commit that introduces a bug, then `git revert` it so the bug-introducing commit is preserved in history but the bug is gone.
- Filename: hw02.sh
▶Toggle solution
#!/usr/bin/env bash
set -euo pipefail
REPO=$(mktemp -d)
cd "$REPO"
git init -q -b main
git config user.name "Demo User" && git config user.email "demo@example.com"
git config commit.gpgsign false
echo "def main(): pass" > main.py
git add . && git commit -q -m "init: main.py"
echo "# WIP work" >> main.py
git stash push -m "WIP"
echo "stash (after) status: $(git status --short | wc -l) change (0 )"
git switch -c fix/patch -q
echo "def patch(): pass" > patch.py
git add . && git commit -q -m "fix: add patch"
git switch main -q
git merge fix/patch -q
echo ""
echo "merge (after) log:"
git log --oneline
git stash pop
echo ""
echo "stash pop (after) main.py:"
cat main.py
echo ""
echo "stash list ( ):"
git stash list
rm -rf "$REPO"
All lecture materials and example code are openly available on GitHub.
View on GitHub ↗