Chapter 2 · Git and GitHub
Undoing Mistakes: restore, reset and revert
- Page 7 of 22
- 16 min read
Everyone makes mistakes with Git: an experiment that breaks the model's output, a scratch notebook staged by accident, a commit message with a typo, a commit that should never have happened, a change that went out to the team and turned out to be wrong. The good news is that Git almost never loses committed work, and nearly every mistake has a clean, specific undo. The bad news is that the undo commands look alike and a few of them do throw work away.
This page gives you one mental model (which of Git's three areas, or the shared history, holds the mistake?) and the exact command for each case: git restore, git commit --amend, git reset and git revert.
What you will learn
- How to throw away edits in the working tree and unstage files with
git restoreandgit restore --staged. - How to fix the last commit's message or contents with
git commit --amend. - What
git reset --soft,--mixedand--hardeach do, and when--hardis dangerous. - Why
git revertis the right undo once you have shared a commit. - A decision table: "what do I want to undo?" → the command.
Set up the example project
A fresh review analyser again, this time with the word lists in their own small file, words.py, and two commits of history:
mkdir -p ~/projects/review-analyser
cd ~/projects/review-analyser
git init -q
cat > words.py <<'EOF'
POSITIVE = {"good", "great", "fast", "love"}
NEGATIVE = {"bad", "slow", "broken", "late"}
EOF
cat > analyse.py <<'EOF'
"""Label product reviews as positive, negative or neutral."""
import csv
from words import NEGATIVE, POSITIVE
def label(text):
words = text.lower().split()
score = sum(w in POSITIVE for w in words) - sum(w in NEGATIVE for w in words)
return "positive" if score > 0 else "negative" if score < 0 else "neutral"
if __name__ == "__main__":
with open("reviews.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
print(label(row["text"]), "|", row["text"])
EOF
cat > reviews.csv <<'EOF'
id,text
1,great phone and fast delivery
2,battery is bad and the box was broken
3,it works
EOF
git add .
git commit -q -m "Add keyword review analyser"
echo "# Review analyser" > README.md
git add README.md
git commit -q -m "Add README"
git log --oneline
python analyse.py5794c66 Add README
7f5e38e Add keyword review analyser
positive | great phone and fast delivery
negative | battery is bad and the box was broken
neutral | it worksFirst ask: where is the mistake?
Every undo depends on where the unwanted change lives right now. Recall the three areas from Version Control and Your First Repository, plus a fourth one you will meet on the next pages: the copy on GitHub that other people have.
working tree staging area local commits shared history
(your files) (the index) (your branch) (pushed to GitHub)
| | | |
git restore FILE git restore --staged git commit --amend git revert COMMIT
(discard edits) FILE (unstage) git reset [--soft|--mixed| (new commit that
--hard] COMMIT undoes an old one)The further right, the more careful you must be. Changes in the working tree that were never committed are the only thing Git cannot bring back once you discard them. Commits that only exist on your machine can be rewritten freely. Commits other people already have must not be rewritten; you add a new commit that undoes them.
Discard edits in the working tree: git restore
Nadia experiments: she empties the negative word list to see what happens, and the analyser now calls a broken box "neutral":
cat > words.py <<'EOF'
POSITIVE = {"good", "great", "fast", "love"}
NEGATIVE = set()
EOF
git diff
python analyse.pydiff --git a/words.py b/words.py
index 32edebb..02d5629 100644
--- a/words.py
+++ b/words.py
@@ -1,2 +1,2 @@
POSITIVE = {"good", "great", "fast", "love"}
-NEGATIVE = {"bad", "slow", "broken", "late"}
+NEGATIVE = set()
positive | great phone and fast delivery
neutral | battery is bad and the box was broken
neutral | it worksThe experiment failed, and she wants the file exactly as it was in the last commit. git restore copies the committed version back over the working tree:
git restore words.py
git status --short
python analyse.pypositive | great phone and fast delivery
negative | battery is bad and the box was broken
neutral | it worksNothing printed by git status --short means a clean working tree, and review 2 is negative again. git restore . does the same for every changed file in the current folder.
Careful: this is one of the few Git commands that really destroys work. The edits you discard were never committed, so there is no copy anywhere and no Git command can bring them back. If you might want them later, commit them on a branch (next page) or use
git stash(advanced tutorial) instead.
Unstage a file: git restore --staged
Now a real improvement: "terrible" belongs in the negative list. While working, Nadia also made a scratch notebook, and a quick git add . staged both:
cat > words.py <<'EOF'
POSITIVE = {"good", "great", "fast", "love"}
NEGATIVE = {"bad", "slow", "broken", "late", "terrible"}
EOF
echo '{"cells": []}' > scratch.ipynb
git add .
git statusOn branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: scratch.ipynb
modified: words.pyGit itself prints the undo in the hint: git restore --staged <file>. It takes the file out of the staging area and leaves your copy on disk untouched:
git restore --staged scratch.ipynb
git status --shortM words.py
?? scratch.ipynbThe first column is the staging area and the second the working tree: M means words.py is modified and staged, ?? means scratch.ipynb is untracked again. The notebook still exists; it just isn't going into the commit. Commit the real change:
git commit -q -m "Add terrible to negative words"
git log --onelinee2fbfcf Add terrible to negative words
5794c66 Add README
7f5e38e Add keyword review analysergit restore can also take a file from any older commit with --source. For example, git restore --source=HEAD~1 words.py puts the word list as it was one commit ago into your working tree (HEAD is the commit you are on, HEAD~1 its parent, HEAD~2 the one before). Nothing is committed until you commit it, and git restore words.py takes you back.
Fix the last commit: git commit --amend
A wrong message
Nadia adds batch processing support and makes a typo in the message:
cat >> analyse.py <<'EOF'
def label_all(texts):
return [label(t) for t in texts]
EOF
git commit -q -am "Add bach labeling"
git log --oneline -1ea145ce Add bach labelinggit commit --amend replaces the last commit with a new one. With -m it gets a new message:
git commit --amend -q -m "Add batch labelling with label_all()"
git log --oneline -1e73f7ad Add batch labelling with label_all()Look at the hash: it changed. An amended commit is a new commit (different message, so different snapshot data, so different hash); the old one is simply no longer on the branch.
A forgotten file
Then she notices that the new function deserves a test review in the sample data, which should have been part of the same commit. Stage it and amend with --no-edit to keep the message:
echo "4,terrible support and late refund" >> reviews.csv
git add reviews.csv
git commit --amend -q --no-edit
git show --stat --oneline HEAD60458d3 Add batch labelling with label_all()
analyse.py | 4 ++++
reviews.csv | 1 +
2 files changed, 5 insertions(+)One commit, both files, as if she had got it right the first time.
Rule: amend only commits you have not pushed yet. Once a commit is on GitHub, teammates may have it, and replacing it with a new hash makes your history and theirs disagree. For shared commits, use
git revert(below).
Move the branch back: git reset
git reset COMMIT moves the current branch to point at an older commit, so the newer commits are no longer on the branch. Its three modes differ only in what happens to the changes those commits contained:
| Command | Branch moves back? | Staging area | Working tree (your files) | Use it to |
|---|---|---|---|---|
git reset --soft HEAD~1 | Yes | Keeps the changes, staged | Unchanged | Redo a commit: change the message or split differently |
git reset HEAD~1 (--mixed, the default) | Yes | Cleared | Unchanged: changes are back as unstaged edits | Undo commits but keep the work to re-pick what to commit |
git reset --hard HEAD~1 | Yes | Cleared | Overwritten to match the commit | Throw the commits and all uncommitted edits away |
Let's try each one on a throwaway commit. Nadia adds a THRESHOLD setting she is not yet sure about, and commits it:
echo "THRESHOLD = -1" >> words.py
git commit -q -am "Try a lower threshold"
git log --oneline -2b624bab Try a lower threshold
60458d3 Add batch labelling with label_all()--soft: undo the commit, keep everything staged
git reset --soft HEAD~1
git log --oneline -1
git status --short60458d3 Add batch labelling with label_all()
M words.py
?? scratch.ipynbThe commit is gone from the branch, and its change is staged (M in the first column), ready to be committed again, perhaps with a better message.
--mixed (the default): undo the commit, keep the edits unstaged
git commit -q -m "Try a lower threshold"
git reset HEAD~1
git status --shortUnstaged changes after reset:
M words.py
M words.py
?? scratch.ipynbGit reports the files it unstaged. The edit is still in words.py, now in the second column (modified, not staged). This is the mode to use when you committed too much at once and want to re-stage it in smaller pieces. (git reset with no commit at all means git reset HEAD: unstage everything, the older way to say git restore --staged ..)
--hard: undo the commit and throw the changes away
git commit -q -am "Try a lower threshold"
git reset --hard HEAD~1
git status --short
cat words.pyHEAD is now at 60458d3 Add batch labelling with label_all()
?? scratch.ipynb
POSITIVE = {"good", "great", "fast", "love"}
NEGATIVE = {"bad", "slow", "broken", "late", "terrible"}The commit is gone and words.py no longer has the THRESHOLD line. --hard also wipes every uncommitted change in tracked files, not just the ones from that commit, so run git status before you use it.
The safety net: git reflog
Was that commit lost forever? No. Git records every position HEAD has been at in the reflog, for about 90 days by default, on your machine only:
git reflog -360458d3 HEAD@{0}: reset: moving to HEAD~1
b624bab HEAD@{1}: commit: Try a lower threshold
60458d3 HEAD@{2}: reset: moving to HEAD~1HEAD@{1} is "where HEAD was one move ago": the threshold commit. git reset --hard HEAD@{1} (or its hash) would bring it straight back. The Advanced tutorial covers recovering lost work with the reflog in detail. Remember the limit, though: the reflog only knows about commits. Uncommitted edits destroyed by git restore or git reset --hard are not in it.
Undo a shared commit: git revert
reset and --amend rewrite history: commits disappear from the branch. That is fine for commits only you have. Once a commit has been pushed and pulled by others, rewriting it causes trouble: their history still contains it, your next push is rejected, and forcing it over theirs can destroy their work.
For shared history use git revert. It leaves the bad commit where it is and adds a new commit that applies the exact opposite change. Suppose this change was pushed, and the next day it turns out that "fine" in customer reviews usually comes with a complaint ("fine, but late"), so labelling it positive was a bad idea:
cat > words.py <<'EOF'
POSITIVE = {"good", "great", "fast", "love", "fine"}
NEGATIVE = {"bad", "slow", "broken", "late", "terrible"}
EOF
git commit -q -am "Count fine as positive"
git revert --no-edit HEADGit prints a short summary of the new commit, as for any commit. Now the log:
git log --oneline -413c3fa4 Revert "Count fine as positive"
6f4269c Count fine as positive
60458d3 Add batch labelling with label_all()
e2fbfcf Add terrible to negative wordsHistory now tells the true story: the change was made, then deliberately undone. Teammates get the revert with a normal pull, and nobody's history is rewritten. Without --no-edit, Git opens your editor with the message Revert "Count fine as positive" so you can add why (a good habit). You can revert any commit by hash, not just the last one, and Git works out the opposite change; if later commits touched the same lines, you resolve a conflict, as on the next page.
git diff HEAD~2 HEAD --statThat prints nothing: the code after the revert is identical to the code before the bad commit.
Throwing away untracked files: git clean
git restore and git reset only touch files Git tracks. Untracked files, like the scratch notebook from earlier, are left alone. git clean deletes them; always do a dry run with -n first, because deleted untracked files are gone for good:
git clean -n
git clean -f
git status --shortWould remove scratch.ipynb
Removing scratch.ipynbAdd -d to remove untracked folders too. Ignored files (your .env, .venv/) are kept unless you add -x, which is one more reason to be careful with git clean -fdx.
Old commands you will see in tutorials
Before Git 2.23, one command, git checkout, did branch switching and file restoring, which confused everybody. git switch and git restore split it in two and have been officially stable since Git 2.51. You will still meet the old forms in Stack Overflow answers and older tutorials:
| Old form | Modern form | Meaning |
|---|---|---|
git checkout -- words.py | git restore words.py | Discard working-tree edits |
git reset HEAD words.py | git restore --staged words.py | Unstage |
git checkout abc1234 -- words.py | git restore --source=abc1234 --staged --worktree words.py | Take a file from an older commit |
git checkout -b feature | git switch -c feature | Create a branch and switch to it (next page) |
What do I want to undo?
| Situation | Command | Loses work? |
|---|---|---|
| I edited a file and want the committed version back | git restore FILE | Yes, the edits |
| I staged a file I don't want in the next commit | git restore --staged FILE | No |
| I want an old version of one file | git restore --source=COMMIT FILE | Overwrites current edits to that file |
| Typo in the last commit message (not pushed) | git commit --amend -m "…" | No |
| Forgot a file in the last commit (not pushed) | git add FILE, git commit --amend --no-edit | No |
| Undo the last commits, keep the changes (not pushed) | git reset --soft HEAD~N or git reset HEAD~N | No |
| Throw away the last commits and all edits (not pushed) | git reset --hard HEAD~N | Yes (commits recoverable via reflog for a while) |
| Undo a commit that is already pushed | git revert COMMIT | No, it adds a commit |
| Delete untracked files | git clean -n, then git clean -f | Yes |
Where this shows up in real AI work
- Experiments: you change a prompt, a feature list or a threshold, the evaluation score drops, and
git restoretakes you back to the known-good version in one command. This is why you commit before every experiment. - Accidental staging: notebooks, output CSVs and model files get swept in by
git add .;git restore --stagedbefore committing (and a good.gitignore) keeps them out. - Production rollbacks: when a merged change makes the deployed model API misbehave, teams
git revertthe merge and redeploy, instead of rewritingmain. The fix is then made calmly on a branch. - Clean pull requests: before pushing a branch, developers use
--amendandreset --softto tidy their local commits so reviewers see a clear story.
Common mistakes
- Running
git reset --hardwith uncommitted work. All edits to tracked files vanish and the reflog cannot help. Fix:git statusfirst; commit or stash anything you might want. - Amending or resetting commits that are already pushed. Your next push is rejected, and forcing it rewrites teammates' history. Fix: for shared commits,
git revert. - Using
git restore FILEwhen you meant--staged. You wanted to unstage, and instead discarded your edits. Fix: read the command twice;--stagedis the safe one. - Expecting
git resetorgit restoreto remove new files. They only touch tracked files; untracked ones stay. Fix:git clean -nto see them, thengit clean -f. - Panicking after a bad reset. Committed work is almost never lost. Fix:
git reflog, find the commit,git reset --hard HASH(or create a branch at it).
Try it yourself
Continue in the same repository, in order.
- Easy: Overwrite
reviews.csvwith just a header line (as a buggy script might), confirm the damage withgit diff --stat, then get the committed version back and count its lines. - Medium: Add "awesome" to the positive words and commit it with the message
wip. Replace that commit with one that has the messageAdd awesome to positive words, without changing its content, usinggit reset --soft(not--amend). - Hard: Imagine the "awesome" commit was pushed and must be undone for everyone. Undo it the shared-history way, then show that "awesome" is no longer in
words.pyand that the original commit is still in the log.
Answers
# Easy
echo "id,text" > reviews.csv
git diff --stat
git restore reviews.csv
wc -l reviews.csv reviews.csv | 4 ----
1 file changed, 4 deletions(-)
5 reviews.csv# Medium
cat > words.py <<'EOF'
POSITIVE = {"good", "great", "fast", "love", "awesome"}
NEGATIVE = {"bad", "slow", "broken", "late", "terrible"}
EOF
git commit -q -am "wip"
git reset --soft HEAD~1
git commit -q -m "Add awesome to positive words"
git log --oneline -2b3455ab Add awesome to positive words
13c3fa4 Revert "Count fine as positive"# Hard
git revert --no-edit HEADgrep -c awesome words.py
git log --oneline -30
11d2063 Revert "Add awesome to positive words"
b3455ab Add awesome to positive words
13c3fa4 Revert "Count fine as positive"grep -c counts matching lines: 0. The log keeps both the change and its revert, so the shared history is never rewritten.
Summary
- First decide where the mistake is: working tree, staging area, local commits, or shared history.
git restore FILEdiscards edits (for good);git restore --staged FILEunstages and keeps them;--source=COMMITfetches an older version.git commit --amendfixes the last commit's message or contents; it creates a new commit, so only use it before pushing.git resetmoves the branch back:--softkeeps changes staged,--mixedkeeps them as edits,--harddiscards them. The reflog can recover commits, never uncommitted edits.- For anything already pushed,
git revertadds a new commit that undoes the old one, without rewriting history.
Next: Branches, Merging and Merge Conflicts. Instead of undoing experiments on main, you will run them on branches, merge the ones that work, and resolve your first merge conflict.