Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 add recorded, 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:

The working tree, the index and HEAD in a column, with the commands that copy content between them, and what git reset overwrites.Working treethe files you editIndexwhat the next commit holdsHEADthe last commitgit reset <commit>moves HEAD there--mixed: also the index--hard: also the filesgit addgit restoregit commitrestore --staged

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:

git restore, one tree at a time Python · restore.py
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:

Interactive · step by step Three restores of menu.txt, one tree at a time Step through the session and watch the three versions of menu.txt, and which one each command overwrites.
Three restores of menu.txt, one tree at a time, step 7 of 7. Showing Working tree (idli, vada), Index (idli, vada), HEAD. In focus: Working tree, Index.git addgit restorerestore --stagedWorking treeidli, vadaIndexidli, vadaHEADidli, vadaidli, vada, to both

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. --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

  1. Question 1 of 2 menu.txt shows MM in git status --short. After git restore menu.txt, what does git status --short show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: M menu.txt: the staged change is left, the newer edit is gone

    git restore menu.txt copies the staged version over the file. In the session dosa stayed, because it was staged, and poha went, so the status changed from MM to M in the first column only.

  2. Question 2 of 2 Which command takes a staged change out of the index and leaves your edit in the file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: git restore --staged menu.txt

    git restore --staged <file> copies HEAD's version into the index, so the change becomes unstaged and stays in your file, as step 2 of the session showed with ' M'.

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:

The same commit undone with --soft, --mixed and --hard Python · reset_modes.py
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:

Interactive · step by step One commit undone three ways Step through the three copies of the repository and watch how far each reset mode reaches down the three trees.
One commit undone three ways, step 5 of 5. Showing main and HEAD (at eeaba07), Index (menu without dosa), Working tree (menu.txt only, no dosa). In focus: main and HEAD.--mixed and --hard--hard onlymain and HEADat eeaba07Indexmenu without dosaWorking treemenu.txt only, no dosaORIG_HEAD is f3a1ffe

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. --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.
  3. --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.
  4. --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.
  5. 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

  1. Question 1 of 2 The commit you undo added prices.txt. What does git status --short show for prices.txt after git reset --soft HEAD~1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A prices.txt: still staged, ready to commit again

    The three copies of the session showed the three answers: A after --soft, ?? after --mixed, and no file at all after --hard.

  2. Question 2 of 2 You ran git reset --hard HEAD~1 and regret it at once. Which command puts the branch back where it was?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: git reset --hard ORIG_HEAD

    ORIG_HEAD names where HEAD was before the reset, so git reset --hard ORIG_HEAD undoes it. It cannot bring back uncommitted edits the hard reset overwrote, which Git never stored.

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:

--keep keeps an unrelated edit and refuses to overwrite a related one Python · reset_keep.py
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:

Four reset modes on fifteen starting states, measured Python · reset_table.py
"""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

  1. Question 1 of 2 hours.txt has an unfinished edit, and the commit you want to drop did not touch hours.txt. What does git reset --keep HEAD~1 do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It removes the commit and keeps the edit, so git status shows ' M hours.txt'

    In the session the first reset dropped the dosa commit and left the unfinished edit of hours.txt; the second refused with exit status 128, because the reset had to change menu.txt, which had an uncommitted line.

  2. Question 2 of 2 In the measured table, the row C D C D (working tree C, index D, HEAD C, target D) ends as C D D with --keep. Why is the file in the folder left holding C?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The index already held the target's version, so --keep moved the branch and the index and left the file alone

    --keep decides by the index entry. Here the index already held D, the target's version, so the index and HEAD take D and the working tree file stays exactly as it was, holding C.

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:

Amending a message, then adding a forgotten file Python · amend.py
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:

Interactive · step by step Two amends, three commits Step through the session and watch main move to a new commit at each amend, while the old ones stay behind.
Two amends, three commits, step 5 of 5. Showing cd5a276 (on no branch now), 35b7257 (on no branch now), 8a89640, main and HEAD. In focus: main and HEAD.cd5a276on no branch now35b7257on no branch now8a89640same message, hours.txtmain and HEADHEAD@{0} to HEAD@{2}

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. One question After git commit --amend, what has happened to the commit you amended?

    Choose one answer.

    Show the answer: One question

    Answer: It still exists, unchanged, on no branch, and the reflog lists it; main points at a new commit

    In the session the first amend put 35b7257 in place of cd5a276 and the second put 8a89640 in place of 35b7257, and git reflog still listed all three, newest first.

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:

After a hard reset: what survives and what does not Python · what_reset_destroys.py
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.

Git Patch and Diff Viewer (Apply, Reverse, Split) Open before-reset.patch to check what it holds, file by file, before you reset.

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

  1. One question You are about to run git reset --hard and there is uncommitted work in the folder. What do you do first?

    Choose one answer.

    Show the answer: One question

    Answer: Read git status, and set aside what matters with git stash push --include-untracked or a patch

    Git can bring back only what it stored. Before a hard reset, read git status and keep what matters, with a stash or a patch made by git diff HEAD, or use --keep, which refuses instead of overwriting.

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:

git clean: look first, then delete Python · clean.py
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

  1. Question 1 of 2 A plain git clean printed "fatal: clean.requireForce is true and -f not given: refusing to clean". What does that mean?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: git clean deletes nothing unless you give -f, or -n for a dry run

    git clean refuses without -f, or -n, unless you change clean.requireForce. Run -n first, read the list, then -f.

  2. Question 2 of 2 In the fest-site session, which dry run listed the untracked folder drafts/?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: git clean -n -d

    git clean -n listed todo.txt, -n -d added drafts/, -n -d -x added the ignored build/ and server.log, and -n -d -X listed only the two ignored ones.

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:

Interactive · answer and compare Which undo fits your change? Answer three questions about the change you want to undo. The table rules out the commands that would do the wrong thing, and says why.
Is the change you want to undo already in a commit?
Do you want to keep the work, or throw it away?
Is it a new file that Git does not track yet?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Eight undo commands against the four things that decide which one is safe for a change
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 push sets 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, then git 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 --hard when 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.

Text Diff Compare two versions of a file, such as the output of git show HEAD~1:menu.txt and the file now, before you decide what to restore.

Checkpoint

Check: Choosing the undo

  1. One question A new, untracked file holds notes you want back later, and you need a clean folder now. Which command fits?

    Choose one answer.

    Show the answer: One question

    Answer: git stash push --include-untracked

    Untracked work is kept with git stash push --include-untracked and thrown away with git clean, after git clean -n has listed it.

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.

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:

gitsession.py, which plays each session Python · gitsession.py
"""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.

  1. Question 1 of 17 You staged an edit to menu.txt and then edited the file again. What does git restore menu.txt leave in the file?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: The staged version, with the first edit and without the second

    Without --staged, git restore copies the file from the index, so the staged edit stays and only the later one is lost. Add --staged --worktree to go back to the last commit.

  2. Question 2 of 17 Which of the three trees does a plain git restore <file> write, and from which one?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: The working tree, from the index

    The diagram of the three trees shows it: git add copies the file into the index, git restore copies the index's version back over the file, and git restore --staged copies HEAD's version into the index.

  3. Question 3 of 17 You want to look at menu.txt as it was two commits ago, in your folder, without moving any branch. Which command does that?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: git restore --source=HEAD~2 menu.txt

    git restore --source=<commit> <file> brings back the file as that commit had it, in the working tree only; commit it if you want to keep it.

  4. Question 4 of 17 Which mode does git reset use when you name none, as in git reset HEAD~1?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: --mixed, which resets the index as well as moving the branch

    git reset with no mode is --mixed: the branch and the index move to the commit you name, and the undone changes stay in your files, unstaged.

  5. Question 5 of 17 The commit you undo with git reset HEAD~1 added a new file, prices.txt. What does git status --short show for it afterwards?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: ?? prices.txt

    The default mode, --mixed, resets the index, which then no longer lists prices.txt. The file stays in the folder, so Git shows it as untracked.

  6. Question 6 of 17 Your last three commits, none of them pushed, should become one. Which reset prepares that, with nothing lost?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: git reset --soft HEAD~3, then one git commit

    The table gives --soft exactly this use, committing the same changes again differently: the branch moves, and the index and the files keep everything, so one commit holds the work of three.

  7. Question 7 of 17 Which reset mode refuses instead of overwriting an uncommitted edit in a file the reset has to change?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: --keep

    Wherever you would type --hard while uncommitted work is around, --keep is the safer habit: it refuses with exit status 128 and changes nothing, as the session showed.

  8. Question 8 of 17 git reset --keep HEAD~1 stops with "Entry 'menu.txt' not uptodate. Cannot merge." What is the most likely cause?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: menu.txt has an uncommitted change, and the reset would have to change menu.txt

    --keep refuses rather than overwrite a local change in a file that differs between HEAD and the target. Commit, stash or discard the change first. If git diff shows no change at all, run git status to refresh the index and try again.

  9. Question 9 of 17 A formatter rewrote menu.txt without changing its content, and now git reset --keep refuses with the not uptodate error. What do you do?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: Run git status, which refreshes the index, then reset again

    Git 2.54.0's --keep can refuse a file whose timestamp changed although its content did not. Commands that refresh the index, such as git status and git diff, clear the problem.

  10. Question 10 of 17 What does git commit --amend do to the last commit?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: It makes a new commit from the index, with the same parent, and moves the branch to it

    The manual calls --amend a rough equivalent of git reset --soft HEAD~1 followed by a commit that starts from the old message: a new commit, a new ID, the old one left behind.

  11. Question 11 of 17 You amended a commit that a teammate had already pulled. What have you changed for the two of you?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: Your branch now holds a commit with a new ID, while their history still holds the old one

    Because the ID changes, amending a commit others have pulled rewrites shared history, just as a reset does. For shared commits, add a new commit, such as a revert or a fix, instead.

  12. Question 12 of 17 After git reset --hard HEAD~1, which of these can Git still bring back? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 12 of 17

    Answer:

    • The content of a new file that was staged but never committed
    • The commit that the reset took off the branch

    The commit is still in the reflog, and git add stored the staged file as a blob that git fsck --lost-found can find. An edit that was never staged and an untracked file were never stored in Git.

  13. Question 13 of 17 Before a risky reset you want one file that holds all your staged and unstaged changes to tracked files, to apply again later. Which command makes it?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: git diff HEAD > before-reset.patch

    git diff HEAD saves every staged and unstaged change to tracked files, though not untracked ones, and git apply before-reset.patch puts them back.

  14. Question 14 of 17 What does git clean -n do?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: It lists the untracked files that git clean -f would delete, and deletes nothing

    Run git clean -n first, read the list, then delete with -f. Add -d for untracked folders and -x or -X for ignored files.

  15. Question 15 of 17 You want to delete the build output that .gitignore lists, build/ and server.log, and keep your untracked notes. Which command does that?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: git clean -f -d -X

    The fest-site dry run git clean -n -d -X listed exactly build/ and server.log, the two ignored items, and left the untracked drafts/ and todo.txt alone.

  16. Question 16 of 17 A bad commit is already on the shared main branch, and teammates have pulled it. How do you undo it?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: git revert with the commit's ID, then push the new commit

    git revert adds a new commit that undoes the bad one, so nobody's history is rewritten. A reset or an amend changes commits that other people already have, and pushing it needs a forced push.

  17. Question 17 of 17 Your last two commits are not pushed and should go, but you have an unfinished edit in another file that you want to keep. Which command fits?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: git reset --keep HEAD~2

    Throwing away unpushed commits is a reset; with local edits around, --keep is the mode that does it without risking them.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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 --staged copies HEAD’s version into the index, and neither moves a branch.
  • git reset moves the branch: --soft keeps the changes staged, --mixed keeps them in the files, --hard deletes them, and --keep refuses rather than overwrite a local edit.
  • git commit --amend replaces 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 -f deleted, which is why git clean -n comes 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.