Git & GitHub Module 8 – Undoing and recovering
Undo changes with restore, reset and amend
Discard edits and unstage files with git restore, undo commits with git reset --soft, --mixed or --hard, fix the last commit with --amend and clean safely.
What you will learn
- Restore working-tree files and unstage changes with git restore
- Explain git reset --soft, --mixed and --hard using the three trees
- Undo the last commit while keeping its changes, and redo it with git commit --amend
- Remove untracked files safely with git clean -n before -f
- Pick the right undo command for a situation with a decision guide
Before you start
What you’ll learn · Before you start
What you will learn
- Restore working-tree files and unstage changes with git restore
- Explain git reset --soft, --mixed and --hard using the three trees
- Undo the last commit while keeping its changes, and redo it with git commit --amend
- Remove untracked files safely with git clean -n before -f
- Pick the right undo command for a situation with a decision guide
Before you start
On this page
Of the five modes of git reset, only --hard can destroy work for good, and then only work that was never staged.
Undoing in Git starts with one question: where is the change you want to undo? An edit you have not staged exists
only in your files, and git restore <file> copies a saved version back over it. A staged change is in the index too,
and git restore --staged <file> takes it out of the index while your edit stays. A commit has moved your branch, and
git reset moves the branch back: --soft keeps the undone changes staged, --mixed, the default, keeps them in
your files, and --hard throws them away. git commit --amend replaces only the last commit, and a commit that others
already have is undone with git revert, which adds a new commit instead of rewriting their history.
Every command here runs on a small canteen repository with real Git 2.54.0: the three trees after each reset mode, four modes measured on fifteen starting states, the one loss nothing repairs, and a guide that picks the undo for your case.
git restore copies a version from one tree to another
Git keeps up to three versions of every tracked file, often called the three trees: The three versions of a tracked file that Git keeps: the working tree you edit, the index that the next commit will hold, and HEAD, the last commit. Every undo command copies a version from one of them to another.:
- the working tree: the files on disk, which you edit;
- the index, also called the staging area: the version
git addrecorded, which the next commit will hold; - HEAD: the version in the last commit of the branch you are on.
git status --short prints a two-letter code per file. The first letter compares the index with HEAD and the second
compares the working tree with the index: M is modified, A added, and ?? marks an untracked file. So MM means
a staged change and a newer edit on top of it. Every undo command copies a version from one tree to another, and the
diagram shows which:
Where add, commit, restore and the reset modes copy a file's content
Text description of the diagram
Three boxes are stacked: the working tree (the files you edit), the index (what the next commit holds) and HEAD (the last commit). Arrows between them name the commands that copy content.
- git add copies a file from the working tree into the index; git restore copies the index's version back over the file. - git commit turns the index into a new commit at HEAD; git restore --staged copies HEAD's version into the index.
A fourth box, below, points at HEAD: git reset <commit> moves HEAD to that commit. With --mixed it also copies the commit's version into the index, and with --hard into the index and the working tree.
git restore writes files and the index, never a branch, so it cannot lose a commit. This session stages one edit to
the menu, dosa, makes another on top of it, poha, and then undoes them one tree at a time:
from gitsession import session
session(r"""
git --version
git init -q -b main canteen
cd canteen
printf 'idli\nvada\n' > menu.txt
git add menu.txt
git commit -q -m "Add the breakfast menu"
# Stage one edit, then make another on top of it
printf 'idli\nvada\ndosa\n' > menu.txt
git add menu.txt
printf 'idli\nvada\ndosa\npoha\n' > menu.txt
git status --short
# 1. Copy the staged version back over the file: poha goes, dosa stays
git restore menu.txt
cat menu.txt
git status --short
# 2. Copy the committed version into the index: the file keeps dosa
git restore --staged menu.txt
git status --short
cat menu.txt
# 3. Put both the index and the file back to the last commit
git add menu.txt
git restore --staged --worktree menu.txt
git status --short
cat menu.txt
""") Output
$ git --version git version 2.54.0 (Apple Git-157) $ git init -q -b main canteen $ cd canteen $ printf 'idli\nvada\n' > menu.txt $ git add menu.txt $ git commit -q -m "Add the breakfast menu" # Stage one edit, then make another on top of it $ printf 'idli\nvada\ndosa\n' > menu.txt $ git add menu.txt $ printf 'idli\nvada\ndosa\npoha\n' > menu.txt $ git status --short MM menu.txt # 1. Copy the staged version back over the file: poha goes, dosa stays $ git restore menu.txt $ cat menu.txt idli vada dosa $ git status --short M menu.txt # 2. Copy the committed version into the index: the file keeps dosa $ git restore --staged menu.txt $ git status --short M menu.txt $ cat menu.txt idli vada dosa # 3. Put both the index and the file back to the last commit $ git add menu.txt $ git restore --staged --worktree menu.txt $ git status --short $ cat menu.txt idli vada
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 restore.py
Step through the same session and watch the three versions of menu.txt, and which one each restore overwrites:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The last commit. The commit Add the breakfast menu holds idli and vada. Right after it, all three trees hold the same menu, and git status shows nothing.
- Stage dosa. dosa is added to the file and git add copies the file into the index. The next commit would now hold idli, vada and dosa.
- Edit poha on top. poha is added to the file but not staged. Each tree now holds a different version, and git status --short shows MM: a staged change, and a newer edit on top of it.
- git restore menu.txt. Without --staged, restore copies the file from the index, not from the last commit. poha is gone and the staged dosa stays, so the status is M in the first column only.
- git restore --staged. --staged copies HEAD's version into the index. The change is unstaged now, shown as M in the second column, and the file in the folder still lists dosa.
- Stage dosa again. git add copies the file with dosa into the index once more, to set up the last restore, which names both trees.
- --staged --worktree. Named with both options, restore takes HEAD as its source for the index and for the file. Both lose dosa, all three trees hold idli and vada again, and git status prints nothing.
Step 1 surprises many people: git restore menu.txt did not go back to the last commit. Without --staged it copies
the file from the index, so the staged dosa stayed and only the later poha went. Step 2, --staged, copied HEAD’s
version into the index; the change is now unstaged ( M), but the file still lists dosa. Step 3 names both trees, and
when --staged is given the source is HEAD unless --source names another commit, so the file is back to the
committed idli and vada.
Two more forms are worth knowing. git restore --source=HEAD~2 menu.txt brings back the file as it was two commits
ago, in the working tree only; commit it if you want to keep it. git restore . discards every unstaged edit below the
current folder at once, with no confirmation and no way back for those edits, so read git diff first.
Version note
git restore and git switch arrived in Git 2.23 to split the two jobs of git checkout, and Git 2.51 declared
them no longer experimental. Older answers online use git checkout -- <file> where this page uses
git restore <file>, and git reset HEAD <file> for git restore --staged <file>; both still work.
Checkpoint
Check: The three trees and git restore
git reset moves the branch, and the mode decides what follows it
git reset <commit> moves the branch you are on, and HEAD with it, to another commit, so the commits after that one
are no longer on the branch. The mode then decides what else is reset. HEAD~1 names the commit before HEAD. This
session makes two commits, copies the repository three times and runs one mode in each copy:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'idli\nvada\n' > menu.txt
git add menu.txt
git commit -q -m "Add the breakfast menu"
printf 'idli\nvada\ndosa\n' > menu.txt
printf 'idli 30\nvada 25\ndosa 40\n' > prices.txt
git add menu.txt prices.txt
git commit -q -m "Add dosa and a price list"
git log --oneline
# Three copies of the same repository, one for each mode
cd ..
cp -R canteen soft
cp -R canteen mixed
cp -R canteen hard
# --soft moves only the branch: the undone commit's changes are still staged
cd ~/soft
git reset --soft HEAD~1
git log --oneline
git status --short
# --mixed, the default, also copies the commit into the index: the changes are only in the files
cd ~/mixed
git reset HEAD~1
git status --short
# --hard also copies it over the files: the changes are in none of the three trees
cd ~/hard
git reset --hard HEAD~1
git status --short
ls
""") Output
$ git init -q -b main canteen $ cd canteen $ printf 'idli\nvada\n' > menu.txt $ git add menu.txt $ git commit -q -m "Add the breakfast menu" $ printf 'idli\nvada\ndosa\n' > menu.txt $ printf 'idli 30\nvada 25\ndosa 40\n' > prices.txt $ git add menu.txt prices.txt $ git commit -q -m "Add dosa and a price list" $ git log --oneline f3a1ffe Add dosa and a price list eeaba07 Add the breakfast menu # Three copies of the same repository, one for each mode $ cd .. $ cp -R canteen soft $ cp -R canteen mixed $ cp -R canteen hard # --soft moves only the branch: the undone commit's changes are still staged $ cd ~/soft $ git reset --soft HEAD~1 $ git log --oneline eeaba07 Add the breakfast menu $ git status --short M menu.txt A prices.txt # --mixed, the default, also copies the commit into the index: the changes are only in the files $ cd ~/mixed $ git reset HEAD~1 Unstaged changes after reset: M menu.txt $ git status --short M menu.txt ?? prices.txt # --hard also copies it over the files: the changes are in none of the three trees $ cd ~/hard $ git reset --hard HEAD~1 HEAD is now at eeaba07 Add the breakfast menu $ git status --short $ ls menu.txt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reset_modes.py
Step through the three copies and watch how far down the three trees each mode reaches:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Before any reset. The last commit, f3a1ffe Add dosa and a price list, added dosa to menu.txt and created prices.txt. Its parent, HEAD~1, is eeaba07 Add the breakfast menu. The session copies this repository three times, once per mode.
- --soft: the branch only. In the first copy, git reset --soft HEAD~1 moves main and HEAD to eeaba07 and stops there. The index still holds the undone commit's changes, so git status --short shows M menu.txt and A prices.txt, staged and ready to commit again.
- --mixed: the index too. In the second copy, git reset HEAD~1, the default mode, also copies eeaba07 into the index. The changes are left in the files only: menu.txt shows as modified, and prices.txt as untracked, ??, because the index no longer lists it.
- --hard: the files too. In the third copy, git reset --hard HEAD~1 also writes eeaba07 over the files. dosa is gone from menu.txt, and prices.txt is deleted, because a tracked file that the target commit lacks is removed. git status shows nothing.
- The way back. In every copy the commit f3a1ffe still exists. Each reset stored the old position in ORIG_HEAD, so git reset --hard ORIG_HEAD undoes a reset at once, but no reset brings back an edit it overwrote.
Every mode moves the branch and HEAD; they differ in what else they reset:
| Mode | Also resets | Use it to |
|---|---|---|
--soft |
nothing | commit the same changes again differently, for example several commits as one |
--mixed |
the index | keep the changes in your files and choose again what to stage; the default mode |
--hard |
the index and the working tree | throw the changes away |
Watch prices.txt, the file the undone commit added. After --soft it is still staged (A). After --mixed it is
untracked (??), because the index no longer lists it. After --hard the file is gone from the folder, because a
tracked file that the target commit does not have is deleted.
Expecting git reset HEAD~1 to keep the changes staged
git reset HEAD~1 is --mixed: it unstages the undone changes, and a file the commit added becomes untracked, as
prices.txt did, so a later git commit -a leaves it out. Use git reset --soft HEAD~1 when the changes should stay
staged, ready for the next commit.
Before it moves the branch, every reset stores the old position in ORIG_HEAD: A reference that git reset, git rebase, git merge and git am set to where HEAD was before they moved it, so git reset --hard ORIG_HEAD undoes the most recent of those moves. More in “Recover lost commits with the reflog”, so
git reset --hard ORIG_HEAD undoes a reset you regret straight away. It cannot bring back uncommitted edits that the
reset overwrote. The reflog: A log of every position that HEAD and each branch has had in one repository, newest first. It is never pushed or cloned. Its entries are named like HEAD@{2}, and apart from stashes they expire after 90 days by default, or 30 for commits no branch reaches. More in “Recover lost commits with the reflog” keeps a longer memory of where the branch has been, and the reflog
lesson later in this module shows how to use it.
Checkpoint
Check: The reset modes
A keep reset refuses where a hard reset would overwrite your edits
--hard overwrites local edits without asking. --keep moves the branch and updates the files that the reset has
to change, but refuses when one of those files has an edit you have not committed:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'idli\nvada\n' > menu.txt
printf 'Open 7 to 10\n' > hours.txt
git add menu.txt hours.txt
git commit -q -m "Add the menu and the opening hours"
printf 'idli\nvada\ndosa\n' > menu.txt
git commit -q -am "Add dosa"
# An unfinished edit in a file that the last commit did not change
printf 'Open 7 to 11\n' > hours.txt
git reset --keep HEAD~1
git log --oneline
git status --short
# Commit dosa again, then leave an edit in menu.txt, which the reset has to change
printf 'idli\nvada\ndosa\n' > menu.txt
git commit -q -m "Add dosa" menu.txt
printf 'idli\nvada\ndosa\nupma\n' > menu.txt
! git reset --keep HEAD~1
git status --short
""") Output
$ git init -q -b main canteen $ cd canteen $ printf 'idli\nvada\n' > menu.txt $ printf 'Open 7 to 10\n' > hours.txt $ git add menu.txt hours.txt $ git commit -q -m "Add the menu and the opening hours" $ printf 'idli\nvada\ndosa\n' > menu.txt $ git commit -q -am "Add dosa" # An unfinished edit in a file that the last commit did not change $ printf 'Open 7 to 11\n' > hours.txt $ git reset --keep HEAD~1 $ git log --oneline f5776ef Add the menu and the opening hours $ git status --short M hours.txt # Commit dosa again, then leave an edit in menu.txt, which the reset has to change $ printf 'idli\nvada\ndosa\n' > menu.txt $ git commit -q -m "Add dosa" menu.txt $ printf 'idli\nvada\ndosa\nupma\n' > menu.txt $ git reset --keep HEAD~1 error: Entry 'menu.txt' not uptodate. Cannot merge. fatal: Could not reset index file to revision 'HEAD~1'. [exit status 128] $ git status --short M hours.txt M menu.txt
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reset_keep.py
The first reset removed the dosa commit and kept the unfinished edit of hours.txt, which that commit had not
touched. The second had to change menu.txt, which had an uncommitted line of upma, so Git stopped with exit status
128 and changed nothing at all. Commit, stash or discard that edit, then reset again. Wherever you would type --hard
while uncommitted work is around, --keep is the safer habit.
--keep also trusts the file timestamps that the index recorded. If a tool rewrites a file without changing its
content, Git 2.54.0 can refuse with the same error although nothing has changed. Commands that refresh the index,
such as git status and git diff, clear the problem, so run one and reset again.
Four modes on fifteen starting states, measured
The git reset manual has tables of what each mode does to a file in each state. This program builds every mix of
states for one file, runs the four modes of this lesson on it with real Git and prints what happened. The fifth mode,
--merge, mainly exists to clear the unmerged entries a conflict leaves in the index, and it too refuses rather than
overwrite an unstaged edit. C is the file in HEAD, D in the commit you reset to, B a change you staged and A a change
you made only in the working tree:
"""Runs every git reset mode on one file in each mix of states, with real Git, and prints what each mode did.
The file is menu.txt. C is its content in the last commit (HEAD), D its content in the commit you reset to (the
target), B a change you staged and A a change you only made in the working tree. Every run starts from a fresh
copy of the same repository, so no run can affect another.
"""
import hashlib
import os
import shutil
import subprocess
import tempfile
from gitsession import git_env
MODES = ["soft", "mixed", "hard", "keep"]
work = tempfile.mkdtemp(prefix="reset-table-")
def git(repo, *args):
return subprocess.run(["git", *args], cwd=repo, env=git_env(work), capture_output=True, text=True)
def put(repo, content):
with open(os.path.join(repo, "menu.txt"), "w") as f:
f.write(content + "\n")
def blob_id(content):
"""The ID Git gives a file's content: the SHA-1 of "blob <size>", a zero byte, then the bytes."""
data = (content + "\n").encode()
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
# One repository with two commits: the target (menu.txt holds D), then HEAD (menu.txt holds C).
template = os.path.join(work, "template")
os.mkdir(template)
git(template, "init", "-q", "--template=", "-b", "main") # no sample hooks: smaller copies
put(template, "D")
git(template, "add", "menu.txt")
git(template, "commit", "-q", "-m", "target")
put(template, "C")
git(template, "commit", "-q", "-am", "head")
commit = {git(template, "rev-parse", rev).stdout.strip(): name for rev, name in [("HEAD", "C"), ("HEAD~1", "D")]}
blob = {blob_id(x): x for x in "ABCD"}
def prepare(working, index):
"""A copy of the template with `index` staged and `working` left in the file."""
repo = tempfile.mkdtemp(dir=work)
shutil.copytree(template, repo, dirs_exist_ok=True)
put(repo, index)
git(repo, "add", "menu.txt")
put(repo, working)
return repo
def reset(prepared, target, mode):
"""Resets a copy of the prepared repository and reads the three trees: the file, the index entry and HEAD."""
repo = tempfile.mkdtemp(dir=work)
shutil.copytree(prepared, repo, dirs_exist_ok=True)
# A copied file has a new timestamp: refresh the index first, as git status or git diff would
git(repo, "update-index", "-q", "--refresh")
if git(repo, "reset", "-q", f"--{mode}", "HEAD" if target == "C" else "HEAD~1").returncode != 0:
return "refused"
with open(os.path.join(repo, "menu.txt")) as f:
now_working = f.read().strip()
head_id, index_id = git(repo, "rev-parse", "HEAD", ":menu.txt").stdout.split()
return f"{now_working} {blob[index_id]} {commit[head_id]}"
print("Before the reset | After: working tree, index, HEAD")
print("working index HEAD target |", " | ".join(f"--{m:<5}" for m in MODES))
for target in ["D", "C"]:
for index in dict.fromkeys(["C", "B", target]):
for working in dict.fromkeys([index, "C", "A", target]):
prepared = prepare(working, index)
results = [reset(prepared, target, m) for m in MODES]
print(f" {working} {index} C {target} |", " | ".join(f"{r:<7}" for r in results))
shutil.rmtree(work) Output
Before the reset | After: working tree, index, HEAD working index HEAD target | --soft | --mixed | --hard | --keep C C C D | C C D | C D D | D D D | D D D A C C D | A C D | A D D | D D D | refused D C C D | D C D | D D D | D D D | refused B B C D | B B D | B D D | D D D | refused C B C D | C B D | C D D | D D D | refused A B C D | A B D | A D D | D D D | refused D B C D | D B D | D D D | D D D | refused D D C D | D D D | D D D | D D D | D D D C D C D | C D D | C D D | D D D | C D D A D C D | A D D | A D D | D D D | A D D C C C C | C C C | C C C | C C C | C C C A C C C | A C C | A C C | C C C | A C C B B C C | B B C | B C C | C C C | B C C C B C C | C B C | C C C | C C C | C C C A B C C | A B C | A C C | C C C | A C C
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reset_table.py
Read each row from left to right: the state before the reset, then the working tree, index and HEAD after each mode.
--soft only ever changes the last letter, --mixed the last two and --hard all three. The --keep column is the
one to study. When HEAD and the target hold the same version, in the last five rows, --keep acts like --mixed and
keeps every edit. When they differ, it refuses as soon as the file has a staged or unstaged change against HEAD,
unless the index already holds the target’s version. Then the working tree file is left exactly as it was: in the
row that starts C D C D, the file still holds C afterwards. The exercise at the end of this lesson turns this table
into code.
Checkpoint
Check: git reset --keep
Amending replaces the last commit with a new one
git commit --amend makes a new commit from the index and puts it in place of the last one, with the same parent and
the same author. Use it for a typo in the message, or for a file that belonged in the commit:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'idli\nvada\n' > menu.txt
git add menu.txt
git commit -q -m "Add the brekfast menu"
git log --oneline
# Fix the message, and add the price list that was meant to be in the same commit
printf 'idli 30\nvada 25\n' > prices.txt
git add prices.txt
git commit -q --amend -m "Add the breakfast menu and prices"
git log --oneline
git show --stat --format=%s HEAD
# One more file, same message: --no-edit keeps it
printf 'Open 7 to 10\n' > hours.txt
git add hours.txt
git commit -q --amend --no-edit
git log --oneline
# The commits that were replaced still exist, and the reflog remembers them
git reflog
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli\nvada\n' > menu.txt
$ git add menu.txt
$ git commit -q -m "Add the brekfast menu"
$ git log --oneline
cd5a276 Add the brekfast menu
# Fix the message, and add the price list that was meant to be in the same commit
$ printf 'idli 30\nvada 25\n' > prices.txt
$ git add prices.txt
$ git commit -q --amend -m "Add the breakfast menu and prices"
$ git log --oneline
35b7257 Add the breakfast menu and prices
$ git show --stat --format=%s HEAD
Add the breakfast menu and prices
menu.txt | 2 ++
prices.txt | 2 ++
2 files changed, 4 insertions(+)
# One more file, same message: --no-edit keeps it
$ printf 'Open 7 to 10\n' > hours.txt
$ git add hours.txt
$ git commit -q --amend --no-edit
$ git log --oneline
8a89640 Add the breakfast menu and prices
# The commits that were replaced still exist, and the reflog remembers them
$ git reflog
8a89640 HEAD@{0}: commit (amend): Add the breakfast menu and prices
35b7257 HEAD@{1}: commit (amend): Add the breakfast menu and prices
cd5a276 HEAD@{2}: commit (initial): Add the brekfast menu
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 amend.py
The commit’s ID changed with each amend: cd5a276 became 35b7257, then 8a89640. Step through the two amends and
watch where main points, and what happens to the commits it leaves:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A commit with a typo. The first commit, cd5a276, holds menu.txt and the message Add the brekfast menu, with a typo. main and HEAD point at it.
- The first amend. prices.txt is staged and git commit --amend -m makes a new commit, 35b7257, from the index, with the fixed message, the same parent and the same author. main moves to it.
- Left behind. cd5a276 is unchanged: Git never edits a commit. It is on no branch now, so git log no longer shows it, but the reflog still names it.
- The second amend. hours.txt is staged and git commit --amend --no-edit keeps the message. Another new commit, 8a89640, takes the place of 35b7257 on main.
- The reflog remembers. git reflog lists all three: 8a89640 as HEAD@{0}, 35b7257 as HEAD@{1} and cd5a276 as HEAD@{2}. Every amend changed the ID, so amending a commit that others have pulled rewrites history they already hold.
Git never changes a commit: the old ones were left behind, and the reflog still lists them. The manual describes
--amend as a rough equivalent of git reset --soft HEAD~1 followed by a commit that starts from the old message,
which matches the output: the branch moved off the old commit and onto a new one. --no-edit keeps the message as it
was. Because the ID changes, amending a commit that other people have already pulled rewrites shared history, just as a
reset does.
Checkpoint
Check: Amending the last commit
A hard reset destroys only what Git never stored
Most mistakes in Git can be undone, but one cannot. This session makes three kinds of work, a commit, two staged new
files and a later edit that is not staged, and then runs git reset --hard HEAD~1:
from gitsession import session
session(r"""
git init -q -b main canteen
cd canteen
printf 'idli\nvada\n' > menu.txt
git add menu.txt
git commit -q -m "Add the breakfast menu"
# Three kinds of work: a commit, two staged new files and a later edit that is not staged
printf 'idli\nvada\ndosa\n' > menu.txt
git commit -q -am "Add dosa"
printf 'idli 30\nvada 25\n' > prices.txt
git add prices.txt
printf 'Open 7 to 10\n' > hours.txt
git add hours.txt
printf 'Open 7 to 11\n' > hours.txt
git status --short
git reset --hard HEAD~1
git status --short
# The commit is still in the reflog
git log --oneline -1 HEAD@{1}
# git add stored both staged files; --lost-found lists what nothing points to, and ignores the reflog
git fsck --lost-found
cat .git/lost-found/other/*
# "Open 7 to 11" was never staged, so Git never stored it: nothing can bring that edit back
""") Output
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli\nvada\n' > menu.txt
$ git add menu.txt
$ git commit -q -m "Add the breakfast menu"
# Three kinds of work: a commit, two staged new files and a later edit that is not staged
$ printf 'idli\nvada\ndosa\n' > menu.txt
$ git commit -q -am "Add dosa"
$ printf 'idli 30\nvada 25\n' > prices.txt
$ git add prices.txt
$ printf 'Open 7 to 10\n' > hours.txt
$ git add hours.txt
$ printf 'Open 7 to 11\n' > hours.txt
$ git status --short
AM hours.txt
A prices.txt
$ git reset --hard HEAD~1
HEAD is now at eeaba07 Add the breakfast menu
$ git status --short
# The commit is still in the reflog
$ git log --oneline -1 HEAD@{1}
23afb42 Add dosa
# git add stored both staged files; --lost-found lists what nothing points to, and ignores the reflog
$ git fsck --lost-found
dangling commit 23afb42882949ecb3bf0ddf8517fe8edc7bc1e09
dangling blob cc41b8c6ff4a1e741bb41c3e26daf27d7047bd53
dangling blob 528b91ed59ebb54c037c8eb5b47c9fc4377e9e61
$ cat .git/lost-found/other/*
Open 7 to 10
idli 30
vada 25
# "Open 7 to 11" was never staged, so Git never stored it: nothing can bring that edit back
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 what_reset_destroys.py
What happened to each kind of work, and the way back where there is one:
| Work before the reset | What Git had stored | The way back |
|---|---|---|
| The commit Add dosa, 23afb42 | the commit, still in the reflog as HEAD@{1} |
git branch rescue HEAD@{1} |
The staged prices.txt and hours.txt |
their contents, as blobs | git fsck --lost-found, then .git/lost-found/other/ |
The unstaged edit Open 7 to 11 |
nothing | none in Git: only an editor’s undo or a backup |
git add stored each staged file as a blob, and git fsck --lost-found lists the objects that nothing points to and
copies the dangling object: An object in the repository that nothing points to: no reference, index entry or other object uses it, such as a blob that was staged but never committed. git fsck lists such objects until garbage collection deletes them. More in “Recover lost commits with the reflog” blobs into .git/lost-found/other/. The file names are lost, but
the contents are there, hours.txt as it was when it was staged. --lost-found ignores the reflog, which is why it
lists the commit as well. Open 7 to 11 was never staged, so Git never stored it, and no Git command can bring it
back.
So before any reset with --hard while uncommitted work is in the folder, run git status and keep what matters.
git stash push --include-untracked sets everything aside, as the next lesson, on stash and worktrees, shows. A patch
works too: git diff HEAD > before-reset.patch saves every staged and unstaged change to tracked files, though not
untracked ones, and git apply before-reset.patch puts them back.
Key idea
Git can bring back only what it stored: commits through the reflog, staged contents as dangling blobs, and never an edit that was not staged.
Checkpoint
Check: What a hard reset destroys
git clean deletes untracked files, so look before you delete
git restore and git reset leave untracked files alone. git clean deletes them, so run it with -n, a dry run,
first: it lists what would go and deletes nothing. This college fest site has an untracked folder of drafts and a
to-do list, and an ignored build folder and log file:
from gitsession import session
session(r"""
git init -q -b main fest-site
cd fest-site
printf 'build/\n*.log\n' > .gitignore
printf '<h1>College fest</h1>\n' > index.html
git add .gitignore index.html
git commit -q -m "Start the fest site"
mkdir build drafts
printf '<h1>College fest</h1>\n' > build/index.html
printf 'Ideas for the quiz night\n' > drafts/quiz.txt
printf 'started\n' > server.log
printf 'Book the hall\n' > todo.txt
git status --short --ignored
! git clean
git clean -n
git clean -n -d
git clean -n -d -x
git clean -n -d -X
git clean -f -d
git status --short --ignored
""") Output
$ git init -q -b main fest-site $ cd fest-site $ printf 'build/\n*.log\n' > .gitignore $ printf '<h1>College fest</h1>\n' > index.html $ git add .gitignore index.html $ git commit -q -m "Start the fest site" $ mkdir build drafts $ printf '<h1>College fest</h1>\n' > build/index.html $ printf 'Ideas for the quiz night\n' > drafts/quiz.txt $ printf 'started\n' > server.log $ printf 'Book the hall\n' > todo.txt $ git status --short --ignored ?? drafts/ ?? todo.txt !! build/ !! server.log $ git clean fatal: clean.requireForce is true and -f not given: refusing to clean [exit status 128] $ git clean -n Would remove todo.txt $ git clean -n -d Would remove drafts/ Would remove todo.txt $ git clean -n -d -x Would remove build/ Would remove drafts/ Would remove server.log Would remove todo.txt $ git clean -n -d -X Would remove build/ Would remove server.log $ git clean -f -d Removing drafts/ Removing todo.txt $ git status --short --ignored !! build/ !! server.log
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clean.py
Without -f or -n, git clean refused, because the setting clean.requireForce is true unless you change it. The
dry runs then showed which option reaches which of the four:
| In the folder | What it is | Deleted by |
|---|---|---|
todo.txt |
an untracked file | git clean -f |
drafts/ |
an untracked folder | git clean -f -d |
server.log |
an ignored file | -f -x, or -f -X |
build/ |
an ignored folder | -f -d -x, or -f -d -X |
-x deletes ignored files as well as untracked ones, and -X deletes only ignored files, a quick way to clear build
output and keep your own notes. -f -d deleted drafts/ and todo.txt and left the ignored files, as the last
status shows. -i shows what would go and lets you choose, by number, by pattern or one file at a time. An untracked
file that git clean deleted was never in Git, so nothing in Git can bring it back.
Checkpoint
Check: Removing untracked files with git clean
Pick the undo from where the change is
Find where your change is, then choose between keeping the work and throwing it away. Answer the three questions for a change of your own, and the table rules out the commands that would do the wrong thing, with the reason:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Command | Moves the branch | Uncommitted edits | Commits others have | Way back |
|---|---|---|---|---|
| git restore <file> Changes: the file, from the index | Good never moves a branch, so no commit is lost | Poor overwrites the unstaged edits of the file | Good touches no commit, so nobody else is affected | Poor an edit it overwrote was never stored, so nothing brings it back |
| git restore --staged <file> Changes: the index, from HEAD | Good never moves a branch | Good the edit stays in the file; only the index changes | Good touches no commit | Good git add stages the change again |
| git stash push -u Changes: the files and the index, saved first | Good never moves a branch | Good saves them, new files included, then empties the folder of them | Good touches no commit | Good git stash pop brings the changes back |
| git clean -n, then -f Changes: untracked files, deleted | Good never moves a branch | Poor deletes untracked files, which Git never stored | Good touches no commit | Poor nothing brings a deleted untracked file back, so -n lists them first |
| git commit --amend Changes: the last commit, replaced by a new one | Mixed a new commit takes the last one's place, so main moves | Good commits what is staged and leaves the rest alone | Poor gives a commit others may have pulled a new ID | Good the replaced commit stays in the reflog |
| git reset --soft or --mixed Changes: the branch, with the changes kept | Mixed moves the branch back, so the undone commits leave it | Good keeps the changes, staged with --soft, in the files with --mixed | Poor rewrites history that others may already have | Good ORIG_HEAD and the reflog still name the old tip |
| git reset --keep Changes: the branch and the files, edits guarded | Mixed moves the branch back and drops the undone commits' changes | Good refuses rather than overwrite an edit in a file it has to change | Poor rewrites history that others may already have | Good ORIG_HEAD and the reflog still name the old tip |
| git revert <commit> Changes: a new commit that undoes an old one | Good only adds one new commit, so the branch moves forward | Mixed needs a clean working tree: commit or stash your edits first | Good rewrites nothing, so it is the undo for shared history | Good the old commit stays in history |
How the answers decide
- “Is the change you want to undo already in a commit?” No, only in my files or the index: rules out git commit --amend, git reset --soft or --mixed, git reset --keep and git revert <commit>. No commit holds the change yet, so there is nothing to amend, reset or revert: undo it in the files or the index.
- “Is the change you want to undo already in a commit?” Yes, in commits only I have: rules out git restore <file>, git restore --staged <file>, git stash push -u and git clean -n, then -f. The change is in a commit now, which file commands cannot reach: move the branch back or replace the commit.
- “Is the change you want to undo already in a commit?” Yes, in commits on a shared branch: rules out git restore <file>, git restore --staged <file>, git stash push -u, git clean -n, then -f, git commit --amend, git reset --soft or --mixed and git reset --keep. Others already have these commits, and amend or reset would rewrite history they hold: add a new commit with git revert.
- “Do you want to keep the work, or throw it away?” Keep it, out of the way or to redo it: rules out git restore <file> and git clean -n, then -f. restore and clean throw the change away: unstage it, stash it, or move the branch with --soft or --mixed instead.
- “Do you want to keep the work, or throw it away?” Keep it, out of the way or to redo it and “Is the change you want to undo already in a commit?” Yes, in commits only I have: rules out git reset --keep. --keep drops the undone commits' changes from your files: to keep them, reset with --soft or --mixed, or amend.
- “Do you want to keep the work, or throw it away?” Keep it, out of the way or to redo it and “Is the change you want to undo already in a commit?” Yes, in commits on a shared branch: adds a caveat to git revert <commit>. A revert takes the change out of the shared branch, but the original commit stays in history, so the work can come back later.
- “Do you want to keep the work, or throw it away?” Throw it away and “Is the change you want to undo already in a commit?” No, only in my files or the index: rules out git restore --staged <file> and git stash push -u. Unstaging and stashing keep the change. To throw it away, restore an edit to a tracked file, or clean an untracked one.
- “Do you want to keep the work, or throw it away?” Throw it away and “Is the change you want to undo already in a commit?” Yes, in commits only I have: rules out git reset --soft or --mixed and git commit --amend. --soft and --mixed keep the undone changes, and an amend keeps the commit's changes: to drop commits, reset with --keep.
- “Is it a new file that Git does not track yet?” Yes, an untracked file: rules out git restore <file> and git restore --staged <file>. restore works on tracked files only: a new file is deleted with git clean, or parked with git stash push -u.
- “Is it a new file that Git does not track yet?” No, a file Git tracks: rules out git clean -n, then -f. git clean deletes only untracked files, so it cannot undo a change to a file that Git tracks.
In short, by where the change is:
- Not staged. Keep: there is nothing to undo, and
git stash pushsets it aside. Throw away:git restore <file>. - Staged. Keep:
git restore --staged <file>. Throw away:git restore --staged --worktree <file>. - Untracked. Keep:
git stash push --include-untracked. Throw away:git clean -n, thengit clean -f. - The last commit, not pushed. Keep: stage a fix, then
git commit --amend. Throw away:git reset --keep HEAD~1. - Several commits, not pushed. Keep:
git reset --soft HEAD~<n>leaves the changes staged,git reset HEAD~<n>unstaged. Throw away:git reset --keep HEAD~<n>, or--hardwhen nothing is uncommitted. - Commits on a shared branch. Either way:
git revert <commit>, a new commit that undoes it. Rewriting shared history needs the team’s agreement.
Two checks keep the guide safe. Before anything with --hard or git clean -f, run git status and read it. Before
you rewrite commits, look at what git status says about your branch and its upstream, for example “ahead of
‘origin/main’ by 2 commits”: only those commits are yours alone to rewrite.
Checkpoint
Check: Choosing the undo
Practise: predict what a reset does
The rules of these four modes fit in a dozen lines of code, and writing them is a good check that you can predict a reset
before you run it. The sample tests are the fifteen rows that reset_table.py measured:
Exercise · Medium · Python
Predict what git reset does to one file
Model git reset for a single file. Write reset_file(mode, working, index, head, target) in reset_model.py. The four arguments after mode are the file's content in the working tree, in the index, in the last commit (HEAD) and in the commit you reset to (the target). Return a tuple (working, index, head) with its content in the three trees after git reset --<mode> <target>, or None when Git refuses and changes nothing.
"soft"moves only the branch: HEAD gets the target's version, the index and the working tree keep theirs."mixed", Git's default, also gives the index the target's version."hard"gives all three trees the target's version."keep"decides by the index entry. If HEAD and the target hold the same version, or the index already holds the target's version, the index gets the target's version and the working tree file is left as it is. If the index holds HEAD's version, all three trees get the target's version, but only when the working tree file has no change either; with a change, Git refuses. Any other content in the index, a change you staged, makes Git refuse too.
The sample tests are the 15 rows of the table that reset_table.py recorded with real Git above, in every mode. They import the function from reset_model.py and run in your browser.
Starter code · reset_model.py
def reset_file(mode, working, index, head, target):
"""The file's (working, index, head) after git reset --<mode> <target>, or None when Git refuses."""
# Replace this line with your code.
return (working, index, head) The sample tests · test_reset_model.py
from reset_model import reset_file
# Recorded by reset_table.py with real Git: the file's content before the reset (working tree, index, HEAD,
# target), then (working tree, index, HEAD) after --soft, --mixed, --hard and --keep; None: Git refused.
ROWS = [
("C", "C", "C", "D", ("C", "C", "D"), ("C", "D", "D"), ("D", "D", "D"), ("D", "D", "D")),
("A", "C", "C", "D", ("A", "C", "D"), ("A", "D", "D"), ("D", "D", "D"), None),
("D", "C", "C", "D", ("D", "C", "D"), ("D", "D", "D"), ("D", "D", "D"), None),
("B", "B", "C", "D", ("B", "B", "D"), ("B", "D", "D"), ("D", "D", "D"), None),
("C", "B", "C", "D", ("C", "B", "D"), ("C", "D", "D"), ("D", "D", "D"), None),
("A", "B", "C", "D", ("A", "B", "D"), ("A", "D", "D"), ("D", "D", "D"), None),
("D", "B", "C", "D", ("D", "B", "D"), ("D", "D", "D"), ("D", "D", "D"), None),
("D", "D", "C", "D", ("D", "D", "D"), ("D", "D", "D"), ("D", "D", "D"), ("D", "D", "D")),
("C", "D", "C", "D", ("C", "D", "D"), ("C", "D", "D"), ("D", "D", "D"), ("C", "D", "D")),
("A", "D", "C", "D", ("A", "D", "D"), ("A", "D", "D"), ("D", "D", "D"), ("A", "D", "D")),
("C", "C", "C", "C", ("C", "C", "C"), ("C", "C", "C"), ("C", "C", "C"), ("C", "C", "C")),
("A", "C", "C", "C", ("A", "C", "C"), ("A", "C", "C"), ("C", "C", "C"), ("A", "C", "C")),
("B", "B", "C", "C", ("B", "B", "C"), ("B", "C", "C"), ("C", "C", "C"), ("B", "C", "C")),
("C", "B", "C", "C", ("C", "B", "C"), ("C", "C", "C"), ("C", "C", "C"), ("C", "C", "C")),
("A", "B", "C", "C", ("A", "B", "C"), ("A", "C", "C"), ("C", "C", "C"), ("A", "C", "C")),
]
def check(mode, column):
for row in ROWS:
working, index, head, target = row[:4]
got = reset_file(mode, working, index, head, target)
assert got == row[column], f"--{mode} with {working} {index} {head} {target}: expected {row[column]}, got {got}"
def test_soft():
"""--soft moves only the branch"""
check("soft", 4)
def test_mixed():
"""--mixed also resets the index"""
check("mixed", 5)
def test_hard():
"""--hard resets all three trees"""
check("hard", 6)
def test_keep():
"""--keep keeps local changes, or refuses when it would have to overwrite them"""
check("keep", 7) A hint
Three of the four modes are one line each: decide which of the three trees each mode overwrites with the target's version, and keep the others as they were. Write "keep" last, as three cases in the order the prompt gives them.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The sessions are Python programs that drive real Git through the helper below, which prints each command after a $
with whatever Git wrote back, and adds [exit status 128] where a command was meant to fail. It fixes the author and
the clock and works in a throwaway home folder, so python3 restore.py, run next to gitsession.py, prints this
page’s commit IDs; the same commands typed by hand give IDs of your own:
"""Plays a terminal session with real Git: each command is printed after "$ ", then what it printed.
Every session starts in a new, empty folder that is also its home folder ("~" in the output), so your own Git
settings play no part, and GIT_CONFIG_NOSYSTEM=1 skips the computer's system-wide settings file. Commits get a
fixed author, Asha Rao, and a fixed clock that moves on one minute per line. A commit's ID covers its author and
time, so the IDs printed here are the ones you get when you run the file yourself. A session started with
fixed_time=False uses the real time instead, for commands whose behaviour depends on how old an entry is.
Each line of a script is one /bin/sh command; the session itself does "cd" and keeps "NAME=$(command)" for the
lines after it, as a shell would. A line that starts with "! " is meant to fail: its exit status is shown. Any
other command that fails stops the session. The folder is deleted at the end.
"""
import atexit
import functools
import os
import re
import shutil
import subprocess
import sys
import tempfile
# The first commit time, in seconds since 1970, and India's offset from UTC (Git's own date format).
START = 1767240000
ZONE = "+0530"
@functools.cache
def _git_folder():
"""The folder that holds the git program. On macOS, /usr/bin/git only starts the Command Line Tools' git."""
found = subprocess.run(["git", "--exec-path"], capture_output=True, text=True).stdout.strip()
return found if found and os.path.isfile(os.path.join(found, "git")) else None
def git_env(home, minute=0, fixed_time=True):
"""The environment every command runs with: no personal settings, a fixed author and a fixed time."""
folder = _git_folder()
stamp = f"@{START + 60 * minute} {ZONE}"
env = {
"PATH": os.pathsep.join(p for p in (folder, os.environ.get("PATH", "/usr/bin:/bin")) if p),
"HOME": home,
"XDG_CONFIG_HOME": os.path.join(home, ".config"),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "Asha Rao",
"GIT_AUTHOR_EMAIL": "asha@example.com",
"GIT_COMMITTER_NAME": "Asha Rao",
"GIT_COMMITTER_EMAIL": "asha@example.com",
"GIT_AUTHOR_DATE": stamp,
"GIT_COMMITTER_DATE": stamp,
"LANG": "C",
"LC_ALL": "C",
"TERM": "dumb",
}
if not fixed_time:
del env["GIT_AUTHOR_DATE"], env["GIT_COMMITTER_DATE"]
return env
def session(script, fixed_time=True):
"""Runs each line of the script in order, starting in the home folder."""
home = tempfile.mkdtemp(prefix="git-lesson-")
atexit.register(shutil.rmtree, home, True)
spellings = sorted({home, os.path.realpath(home)}, key=len, reverse=True)
saved = {}
def tidy(text):
for spelling in spellings:
text = text.replace(spelling, "~")
return text
cwd = home
for minute, line in enumerate(script.strip().splitlines()):
command = line.strip()
if not command:
continue
if command.startswith("#"):
print(command)
continue
should_fail = command.startswith("! ")
if should_fail:
command = command[2:]
print(f"$ {command}")
if command.startswith("cd "):
target = command[3:].strip()
if target == "~" or target.startswith("~/"):
target = home + target[1:]
cwd = os.path.normpath(os.path.join(cwd, target))
continue
keep = re.fullmatch(r"([A-Za-z_]\w*)=\$\((.+)\)", command)
done = subprocess.run(
keep.group(2) if keep else command,
shell=True,
cwd=cwd,
env={**git_env(home, minute, fixed_time), **saved},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE if keep else subprocess.STDOUT,
text=True,
)
if keep:
saved[keep.group(1)] = done.stdout.strip()
print(tidy(done.stderr), end="")
else:
print(tidy(done.stdout), end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if not should_fail:
sys.exit(1)
elif should_fail:
sys.exit(f"{command!r} was meant to fail, and it worked") Then test yourself on the seven ideas of this lesson. Every answer is explained at once, and the questions keep returning to the idea you have got wrong most often:
Practice that adapts to you
Practise this lesson
17 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between git reset --soft, --mixed and --hard?
All three move the current branch, and HEAD with it, to the commit you name, so the commits after it leave the
branch. They differ in what else they reset. --soft stops there: the index and the working tree keep their
contents, so the undone changes show as staged, ready to commit again. --mixed, the default, also resets the index,
so the changes stay in your files but are unstaged, and a file the undone commit added becomes untracked. --hard
also overwrites the working tree, so the changes are gone from all three trees. Uncommitted edits that --hard
overwrites cannot be recovered, while the undone commits stay in the reflog for a while. With local edits to protect,
--keep is the safer choice, because it refuses instead of overwriting them.
Two harder questions start from this lesson’s sessions: commits on the wrong branch, one of them already pushed, and a commit that has to become two.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Three commits on main should have gone on a feature branch. The oldest of them is already pushed to origin/main; the two newer ones exist only on your machine. What do you do with each, and why?
-
Mid to senior
Your last commit mixes a bug fix with an unrelated refactor, and nothing is pushed. Turn it into two commits that each build and pass the tests. Which commands do you run, in what order, and how do you prove that the final code is unchanged?
Key takeaways
- Every undo starts with where the change is: in the working tree, in the index, in a local commit or in a pushed commit.
git restore <file>copies the index’s version over the file and--stagedcopies HEAD’s version into the index, and neither moves a branch.git resetmoves the branch:--softkeeps the changes staged,--mixedkeeps them in the files,--harddeletes them, and--keeprefuses rather than overwrite a local edit.git commit --amendreplaces the last commit with a new one, and the replaced commit stays in the reflog.- After a hard reset, commits and staged contents can still be found, but an edit that was never staged is gone, and
so is an untracked file that
git clean -fdeleted, which is whygit clean -ncomes first. - Pushed commits are undone with
git revert, which adds a commit, rather than with a reset or an amend.
Terms in this lesson
- three trees
- The three versions of a tracked file that Git keeps: the working tree you edit, the index that the next commit will hold, and HEAD, the last commit. Every undo command copies a version from one of them to another.
References
- git-restore documentation (The Git project)
- git-reset documentation (The Git project)
- git: reset, restore and revert (The Git project)
- git-commit documentation (--amend) (The Git project)
- git-clean documentation (The Git project)
- git-status documentation: short format (The Git project)
- git-revert documentation (The Git project)
- git-fsck documentation (--lost-found) (The Git project)
- git-update-index documentation: using --refresh (The Git project)
- giteveryday: a useful minimum set of commands (The Git project)
- Git 2.23 release notes (git switch and git restore) (The Git project)
- Git 2.51 release notes (switch and restore no longer experimental) (The Git project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress