The previous lesson set up the catalogue and solved the one-minute cases. Now it is the turn of the command that appeared in half a dozen rows of that table and that we have been promising for five modules: git reset.

It is probably the worst understood command in Git. It has three modes that do different things, two ways of invoking it that behave in completely different ways, and a reputation for being dangerous that is only deserved in one of its uses. The confusion is not accidental: reset does two jobs that in other systems would be two separate commands.

This lesson takes it apart piece by piece and then puts it in context. Because reset is not the answer to "I want to undo something": it is one of the answers, and choosing the wrong one is what turns an unwanted change into a problem for the whole team. By the end you will have a decision table that answers the only question that matters: where is the thing I want to undo?

And a warning that holds for the whole lesson: this is where the one Git command that truly destroys, with no possible safety net, makes its appearance. It is not reset. It is git clean.

Contents

  1. The three areas, once again
  2. git reset moves the branch: the central idea
  3. The three modes: --soft, --mixed, --hard
  4. Cases arranged by what you want to undo
  5. git reset <commit> -- <path>: the other form
  6. git restore: the modern command for everyday work
  7. git clean: the one that really does destroy
  8. ORIG_HEAD: the immediate safety net
  9. The decision table

  1. The three areas, once again

Every reset becomes clear as soon as you recall the three areas from lesson 01-03. It is worth having them in front of you:

flowchart LR
    WD["Working directory<br/>(the files you edit)"]
    IDX["Index / staging<br/>(.git/index)"]
    HEAD["HEAD → branch → commit<br/>(the database)"]

    WD -->|"git add"| IDX
    IDX -->|"git commit"| HEAD
    HEAD -->|"git reset --hard"| WD
    HEAD -->|"git reset --mixed"| IDX

Three snapshots of the project that may or may not agree:

Area What it contains Where it lives
HEAD The last commit on the current branch .git/refs/heads/<branch> (41 bytes)
Index What would go into the next commit .git/index (binary)
Working directory The actual files on disk Your folder

When git status says "Changes to be committed", it is comparing the index with HEAD. When it says "Changes not staged for commit", it compares the working directory with the index. With that clear, reset stops being a mystery: each mode decides how far through those three areas the change reaches.

  1. git reset moves the branch: the central idea

Before the modes, the basic operation. This is what git reset <commit> does always, in all three modes:

It moves the current branch so that it points at <commit>. And since HEAD points at the branch, HEAD moves along with it.

It does not delete commits. It does not modify commits. It rewrites a 41-byte file (lesson 03-01).

flowchart LR
    subgraph A["Before"]
        direction LR
        a1["e91d4a8"] --> a2["4f8a2e6"] --> a3["b52c9d1"] --> a4["7d3a8f4"]
        ra(["GT-241"]) -.-> a4
        ha(["HEAD"]) -.-> ra
    end
    subgraph B["git reset HEAD~2"]
        direction LR
        b1["e91d4a8"] --> b2["4f8a2e6"] --> b3["b52c9d1<br/>(unreachable)"] --> b4["7d3a8f4<br/>(unreachable)"]
        rb(["GT-241"]) -.-> b2
        hb(["HEAD"]) -.-> rb
    end
    A ==> B

b52c9d1 and 7d3a8f4 still exist in full in .git/objects. What has changed is that GT-241 no longer reaches them, so git log does not show them. Recovering them is trivial as long as the reflog remembers them (lesson 09-04).

From this come the two consequences you need to internalise:

1. reset is safe on local work. Even if you get it wrong, the commits are still there.

2. reset is dangerous on published work. Because moving the branch backwards and then forwards again produces a different history from the one others have. The push will be rejected (non-fast-forward), and forcing it would destroy other people's work. It is the golden rule from lesson 05-01, and its full treatment is lesson 09-03.

A third nuance that hardly anyone knows about and that avoids a lot of scares:

git reset            # with no commit: equivalent to git reset --mixed HEAD

With no commit argument, reset uses HEAD, which is to say it moves nothing. It only acts on the index. That is why a bare git reset is completely harmless: it empties the staging area and leaves your files intact.

  1. The three modes: --soft, --mixed, --hard

The difference between the three is how far it propagates the change.

Mode Moves HEAD/branch Rewrites the index Rewrites the working directory Does it destroy work?
--soft Yes No No No
--mixed (default) Yes Yes No No
--hard Yes Yes Yes Yes: whatever is uncommitted
--merge Yes Yes Partially (keeps non-conflicting local changes) Rarely
--keep Yes Yes Partially (aborts if there is a conflict) No

Visually, over the three areas:

flowchart TD
    subgraph S["--soft"]
        s1["HEAD ⟵ moves"]
        s2["Index: untouched"]
        s3["Working tree: untouched"]
    end
    subgraph M["--mixed (default)"]
        m1["HEAD ⟵ moves"]
        m2["Index ⟵ rewritten"]
        m3["Working tree: untouched"]
    end
    subgraph H["--hard"]
        h1["HEAD ⟵ moves"]
        h2["Index ⟵ rewritten"]
        h3["Working tree ⟵ OVERWRITTEN"]
    end

--soft: undo the commit, keep everything staged

git reset --soft HEAD~1
git status --short
M  app.js
A  styles.css

The commit has disappeared from the branch, and all its content is staged, ready to be committed again. The M and A with the letter in the first column mean "in the index".

It is the "I want to redo this commit" mode. In fact, it is exactly what git commit --amend does under the bonnet.

--mixed: undo the commit and unstage

git reset HEAD~1          # --mixed is the default
git status --short
 M app.js
?? styles.css

Now the letter is in the second column: the changes are in the working directory, unstaged. And styles.css, which was new, has gone back to being an untracked file.

It is the "I want to redo this commit and choose again what goes in" mode. It is the one you used in lesson 05-02 to split a commit in two.

--hard: undo the commit and the content

git reset --hard HEAD~1
git status
On branch GT-241
nothing to commit, working tree clean

Everything clean, as if that commit had never existed. And here lies the danger, which is worth stating precisely:

--hard does not destroy the commits (they remain in the database). It destroys the uncommitted changes that existed at that moment in the index and in the working directory.

That is what cannot be undone, because it was never in .git/objects (lesson 09-01, table in section 1).

An important and reassuring exception: --hard does not touch untracked files. If you had a notes.txt that you never added, it is still there. What it sweeps away are the modifications to tracked files.

The comparison in a single experiment

# Starting point: a commit, and also an uncommitted change
git log --oneline -2
git status --short
7d3a8f4 (HEAD -> GT-241) GT-241 pending filter
b52c9d1 GT-241 filter groundwork
 M app.js
Command git log -1 app.js (my uncommitted change) Content of the undone commit
git reset --soft HEAD~1 b52c9d1 Kept In the index
git reset --mixed HEAD~1 b52c9d1 Kept (mixed in) In the working directory
git reset --hard HEAD~1 b52c9d1 LOST Discarded

The --hard row is the one to memorise: it sweeps away two things at once, the commit and your work in progress. And only one of the two can be recovered.

--merge and --keep: the modes hardly anyone uses

They exist, and in two situations they are exactly what you want:

# Move the branch but try to KEEP my local changes
git reset --keep <commit>

--keep is a well-mannered --hard: it moves the branch and updates the files that differ, but aborts if that would trample a local change. It is the sensible option when you want to go back and are not sure you have everything committed:

error: Entry 'app.js' not uptodate. Cannot merge.

That error, here, is good news: it has just saved you.

--merge is similar but tries to merge the local changes into the destination. It is used mostly to abort a merge by hand.

Practical rule: when you are tempted to type --hard and are not sure, type --keep. If it works, there was nothing to lose; if it fails, you have just avoided the problem.

  1. Cases arranged by what you want to undo

This is the recipe book. It is arranged from the smallest scope to the largest.

4.1. Discard a file's changes in the working directory

Ana has been trying things out in styles.css and wants to go back to how it was.

git restore styles.css              # modern form (Git 2.23+)
git checkout -- styles.css          # old form, equivalent

This one really does destroy, and with no net: uncommitted modifications are nowhere at all. Git does not even ask.

# Before destroying, look at what you are about to lose
git diff styles.css

# The whole working directory
git restore .

4.2. Take something out of the index without losing the change

Bruno has run git add . and has accidentally swept in personal-notes.md.

git restore --staged personal-notes.md     # modern form
git reset personal-notes.md                # old form, equivalent

You have lost nothing: the file is still modified in the working directory, it merely stops being staged.

git status --short
M  app.js
?? personal-notes.md

Note the asymmetry, which is source of confusion number one:

Removes from the index Destroys the change
git restore --staged <f> Yes No
git restore <f> No Yes
git restore --staged --worktree <f> Yes Yes

git restore with no options acts on the working directory. With --staged, on the index. It is the opposite of what many people assume, and it is the reason git restore deserves a section of its own (section 6).

4.3. Undo the last commit while keeping the changes

The most frequent case of all. Carla has committed and realised that the commit mixes two things together.

git reset --soft HEAD~1

You have lost nothing. The commit is no longer on the branch, but all its content is staged. Now she can redo it:

git status --short
M  app.js
M  styles.css

If she also wants to choose again what goes into each commit, --mixed and git add -p (lesson 02-04):

git reset HEAD~1
git add -p app.js            # choose hunk by hunk
git commit -m "GT-241 calculate the counter over the complete list"
git add .
git commit -m "GT-241 styles for the pending indicator"

That is the complete "I have mixed two things into one commit" flow, and it is the one used every day.

4.4. I only want to change the message

git commit --amend

You do not need reset for this. --amend replaces the last commit (lesson 02-04), and with -m it does not even open the editor:

git commit --amend -m "GT-241 calculate the counter over the complete list"

Remember that the hash changes: if it was published, this rewrites history.

4.5. Throw away the last N commits

git reset --hard HEAD~3

Three commits off the branch. Before running it, two habits that cost five seconds:

# 1. Note down where you are
git log --oneline -1
git rev-parse HEAD > /tmp/where-i-was.txt

# 2. Or better: plant a marker. It is free and it does not get forgotten
git branch backup-GT-241

With git branch backup-GT-241, the --hard stops being irreversible in any sense: the commits remain reachable from the backup. It is the same pattern as in lesson 09-01, section 5.2, and the one generalised in 09-04.

And if you have already done it without a backup: no matter, as long as they were not uncommitted changes.

git reflog -5                       # look for the hash prior to the reset
git reset --hard b52c9d1            # or better: git branch rescue b52c9d1

4.6. Return the branch to exactly how it is on the remote

git fetch origin
git reset --hard origin/main

This is the "throw away everything of mine and leave me like the server". Useful when your local copy has got hopelessly tangled and you know there is nothing of yours worth saving.

Check first what you are about to throw away:

git log --oneline origin/main..HEAD      # commits of mine that are not on the remote
git status --short                       # uncommitted changes

If the first list is not empty, do not run the --hard without creating a backup branch first.

4.7. Undo something that is already published

Here reset is not the answer. Rewriting a published branch forces you to force the push and destroys the work of anyone who has the previous version.

The answer is git revert (lesson 05-06): it creates a new commit with the inverse change, it rewrites nothing, there is no need to force, and there is a record of it.

git revert 4f8a2e6
git push

The only exception: a published branch that is exclusively yours (your GT-241 working branch, which nobody else uses). There you can indeed rewrite and force with --force-with-lease, and that is lesson 09-03.

  1. git reset <commit> -- <path>: the other form

Here is the main source of confusion with reset, and it deserves a section of its own.

When you add a path, git reset does something completely different:

git reset <commit> -- <path> does NOT move the branch. It copies the state of <path> at <commit> into the index, and leaves the working directory untouched.

Form Does it move the branch? Does it touch the index? Does it touch the working directory?
git reset <commit> Yes Depending on the mode Depending on the mode
git reset <commit> -- <path> No, never Yes, only that path No, never

Why it cannot move the branch: moving HEAD to another commit is an operation on the entire repository. There is no such thing as "moving the branch for just one file". So when you give a path, Git understands that you want the other operation: filling the index from a commit.

A direct consequence: git reset <commit> -- <path> does not accept --hard.

git reset --hard HEAD~1 -- app.js
fatal: Cannot do hard reset with paths.

Precisely because the --hard mode implies touching the working directory, and this form never does.

What it is actually for

It is the way to stage the old content of a file, typically in order to undo only part of a commit:

# Commit 4f8a2e6 touched three files; I want to undo only the styles.css part
git reset 4f8a2e6~1 -- styles.css
git status --short
M  styles.css

The index now has the previous version of styles.css, staged. A git commit creates a commit that undoes only that part.

And if you also want the file on disk to change, the modern and far clearer form is git restore:

git restore --source=4f8a2e6~1 --staged --worktree styles.css

The "that file should not be in the repository" case

Picking up from lesson 08-03:

git rm --cached local-config.json

git rm --cached removes the file from the index (it stops being tracked) but keeps it on disk. It is the operation to perform when something got into the repository and you then added it to .gitignore. It is a close cousin of git reset -- <path>, but for stopping tracking the file rather than restoring its content.

  1. git restore: the modern command for everyday work

Git 2.23 split the old git checkout into two commands with honest names: git switch for branches (lesson 03-02) and git restore for files. It is the tool you should be using every day, leaving reset for what genuinely involves moving the branch.

# Discard working directory changes
git restore app.js
git restore .

# Remove from the index (keeping the change on disk)
git restore --staged app.js

# Both: leave the file as it is in HEAD, with no trace of the change
git restore --staged --worktree app.js

# Bring a file across from another commit or branch
git restore --source=HEAD~3 app.js
git restore --source=origin/main styles.css

# Interactive: choose hunk by hunk what to discard
git restore -p app.js

The equivalence table with the old forms, because you will see both in documentation and in forum answers:

Goal Modern Old
Discard changes on disk git restore <f> git checkout -- <f>
Remove from the index git restore --staged <f> git reset HEAD <f>
Bring a file from another commit git restore --source=<c> <f> git checkout <c> -- <f>
Switch branch git switch <branch> git checkout <branch>
Create and switch git switch -c <branch> git checkout -b <branch>

git restore -p deserves a separate mention. It is the sibling of git add -p: it shows you each hunk of the diff and asks whether you want to discard it. It is the surgical way of undoing part of a change without losing the rest, and it avoids the "I am going to discard everything and redo it".

git restore -p app.js
@@ -12,6 +12,9 @@ function renderTasks() {
   list.innerHTML = '';
+  console.log('DEBUG tasks', tasks);
   tasks.forEach(task => {
Discard this hunk from worktree [y,n,q,a,d,e,?]?

Perfect for removing debugging console.logs while keeping the real work.

  1. git clean: the one that really does destroy

Everything above has a safety net to some degree. This command has none at all, and that is why it deserves the most serious tone in the lesson.

git clean deletes untracked files. And an untracked file has never passed through .git/objects: there is no commit, no blob, no reflog, no fsck that can help. When git clean deletes it, it is deleted, just as with rm.

The absolute rule: -n first, always

git clean -n
Would remove dump.sql
Would remove screenshots/
Would remove personal-notes.md

-n (or --dry-run) deletes nothing: it only lists. Read the whole thing. If something you care about shows up in that list, do not run the real command.

This habit is not optional. It is the difference between tidying up the project and losing the local configuration file you have been maintaining by hand for six months.

The options

Option What it does Danger
-n / --dry-run Only lists what it would delete None
-f / --force Deletes for real (mandatory: without it, it does nothing) High
-d Includes untracked directories High
-x Also includes what is ignored by .gitignore Very high
-X Deletes only what is ignored, keeping the rest Medium
-i / --interactive Asks file by file Low
-e <pattern> Excludes whatever matches

The usual uses:

# What you almost always want: look, and then delete new files and directories
git clean -nd
git clean -fd

# Leave the project as freshly cloned (it also deletes node_modules, .env, builds!)
git clean -ndx
git clean -fdx

# Delete only what is ignored: clear out build artefacts while keeping the rest
git clean -fdX

# The safe mode when you are unsure
git clean -id

-x is the one to look at twice. It deletes what .gitignore protects, and that is usually precisely where the things that are not in the repository but do matter to you live: .env with the local configuration, development credentials, test databases.

Interactive mode

git clean -id
Would remove the following items:
  screenshots/  personal-notes.md  dump.sql

*** Commands ***
    1: clean                2: filter by pattern    3: select by numbers
    4: ask each             5: quit                 6: help
What now> 4

Option 4 (ask each) asks one by one. When there are many files and only some are surplus, it is the right way to do it.

The "total reset" and its cost

The combination that circulates on the internet as "leave it all clean":

git reset --hard HEAD && git clean -fdx

That leaves the directory exactly like a freshly made clone of that commit. And it sweeps away, without asking and with no possibility of recovery:

  • All uncommitted changes to tracked files.
  • All the new files you had not added.
  • Everything ignored: .env, node_modules/, local configurations, test databases.

It is a legitimate command — in CI, in a container, in a throwaway repository — and a catastrophe on the machine of someone who has been working for two hours. Never paste it without having run git clean -ndx first and read the list.

The only prevention that works

# Before any dubious clean-up
git stash push -u -m "just in case"

git stash -u (lesson 05-04) also saves the untracked files, and it saves them as Git objects. With that, what you were about to delete moves into the database and therefore stops being unrecoverable. It costs one second.

  1. ORIG_HEAD: the immediate safety net

Before an operation that moves HEAD substantially — reset, merge, rebase, pull — Git saves the previous position in a special reference:

cat .git/ORIG_HEAD
git rev-parse ORIG_HEAD

So the immediate "undo" of a reset is:

git reset --hard ORIG_HEAD

Or, following the safe pattern:

git branch rescue ORIG_HEAD

Two warnings about ORIG_HEAD:

  • It only saves one position, that of the last operation. If you do two resets in a row, the first one is lost. That is what the reflog is for, which saves the entire history of movements (lesson 09-04).
  • It does not recover uncommitted changes. ORIG_HEAD is a commit hash: it puts the branch back where it belongs, not your work in progress.

It is the safety net for the next thirty seconds. For everything else, 09-04.

  1. The decision table

The right question is not "which command do I use to undo?", but "where is the thing I want to undo?". With that answer, the command is immediate.

flowchart TD
    Q1{"Is it committed?"}
    Q1 -->|No| Q2{"Is it staged<br/>(git add)?"}
    Q1 -->|Yes| Q3{"Is it published<br/>on the remote?"}

    Q2 -->|No| R1["git restore &lt;f&gt;<br/>(or git clean if it is untracked)"]
    Q2 -->|Yes| R2["git restore --staged &lt;f&gt;"]

    Q3 -->|No| Q4{"Only the last<br/>commit?"}
    Q3 -->|Yes| R5["git revert (05-06)<br/>or --force-with-lease if the branch is yours (09-03)"]

    Q4 -->|Yes| R3["git commit --amend<br/>or git reset --soft HEAD~1"]
    Q4 -->|No| R4["git reset HEAD~N<br/>or git rebase -i (05-02)"]

And in table form, which is how it gets consulted in the moment:

I want to undo... State Command Recoverable?
Changes to an edited file Uncommitted git restore <f> No
Part of a file's changes Uncommitted git restore -p <f> No
A git add Staged git restore --staged <f> Yes (nothing is lost)
A new file that is surplus Untracked git clean -n and then -f No
The last commit, keeping the work Unpublished git reset --soft HEAD~1 Yes (reflog)
The last commit's message Unpublished git commit --amend Yes (reflog)
A file forgotten in the last commit Unpublished git add <f> && git commit --amend --no-edit Yes (reflog)
The last N commits and their content Unpublished git reset --hard HEAD~N The commits yes; the uncommitted work no
A commit in the middle of the branch Unpublished git rebase -i with drop (05-02) Yes (reflog)
Just one file from a commit Unpublished git reset <c>~1 -- <f> and commit Yes
A commit that is already published Published git revert <sha> (05-06) Yes (it is additive)
A merge that is already published Published git revert -m 1 <sha> (05-06) Yes
A commit on my published personal branch Published, own branch reset + push --force-with-lease (09-03) Yes, with a warning to others
Everything: go back to the remote's state Any git fetch && git reset --hard origin/<branch> The commits yes; the uncommitted work no
Everything, including new files Any git reset --hard && git clean -fdx No for the uncommitted work

The row that governs all the others is the "is it published?" one. If the answer is yes and the branch is shared, reset drops off the list and the only correct tool is revert.

Common Mistakes and Tips

Mistake 1: believing that reset --hard deletes commits. It does not delete them: it moves the branch. The commits are still in .git/objects and the reflog finds them (09-04). What it does destroy are the uncommitted changes.

Mistake 2: using --hard when --soft would have done. If all you wanted was to redo the commit, --soft keeps everything staged. --hard also sweeps away your work in progress.

Mistake 3: confusing git restore <f> with git restore --staged <f>. The first destroys the change on disk; the second only takes it out of the index and loses nothing. They are almost opposites.

Mistake 4: expecting git reset <commit> -- <path> to move the branch. It never does, and that is why --hard with paths gives an error. It is a different operation with the same name.

Mistake 5: running git clean -fdx copied from the internet. It deletes .env, local configurations and everything ignored, with no possibility of recovery. -n first, always.

Mistake 6: running reset on a shared branch and forcing the push. It destroys the work of anyone who had the commits. On published material, revert (05-06).

Mistake 7: using git reset --hard origin/main without checking what of yours is ahead. git log --oneline origin/main..HEAD costs a second and tells you exactly what you are about to throw away.

Mistake 8: forgetting that --hard respects untracked files. It is not a serious mistake, but it explains the "I ran reset --hard and this is still here": it was untracked.

Tip 1: when you hesitate between --hard and doing nothing, use --keep. It does the same thing, but aborts instead of trampling local changes.

Tip 2: git branch backup-<something> before any reset --hard. It costs two seconds and turns the operation into something trivially reversible.

Tip 3: use git restore and git switch instead of git checkout. The names say what they do and they prevent mistakes born of confusion.

Tip 4: git restore -p for removing the console.logs. Surgical, and it keeps the rest of the work.

Tip 5: git stash push -u before any clean-up. It turns the unrecoverable into the recoverable for one second of effort.

Tip 6: remember ORIG_HEAD. The immediate undo for a reset, merge, rebase or pull you have just regretted.

Exercises

Exercise 1: the three modes, from the same starting point

  1. Create a repository with app.js and three commits. In the third one, also add styles.css.
  2. Modify app.js without committing and create a new file notes.txt without adding it.
  3. Run git reset --soft HEAD~1 and note down the output of git status --short and git log --oneline -1.
  4. Undo it with git reset --hard ORIG_HEAD... and observe what has happened to your modification of app.js. Explain it.
  5. Repeat the complete experiment with --mixed and with --hard, and fill in a table with three columns: log -1, the uncommitted change in app.js, and notes.txt.
  6. Explain why notes.txt survives even --hard.

Exercise 2: reset with a path versus reset without one

  1. On a repository with four commits, where the third modified app.js, styles.css and index.html, try git reset --hard HEAD~1 -- app.js and explain the error.
  2. Use git reset HEAD~2 -- styles.css and check with git status, git diff and git diff --cached which area the change has ended up in.
  3. Commit. Check that you have undone only the styles.css part of the third commit and that the rest is still in the project.
  4. Achieve the same result with git restore --source=... --staged --worktree and compare the state of the working directory in both cases.

Exercise 3: the real limit of recovery

  1. In a test repository, create three situations at once: an unpublished commit, a change staged with git add, and a change on disk only.
  2. Run git reset --hard HEAD~1.
  3. Recover the commit using the reflog.
  4. Try to recover the staged change with git fsck --lost-found and git cat-file -p.
  5. Try to recover the change that was on disk only. Document why it is not possible.
  6. Repeat the whole exercise having run git stash push -u beforehand and explain what changes.

Solutions

Solution 1:

mkdir /tmp/p9-02 && cd /tmp/p9-02 && git init -q -b main
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"

echo "// task-manager" > app.js && git add . && git commit -q -m "chore: start"
echo "// list" >> app.js && git commit -q -am "feat: list"
echo "// filter" >> app.js && echo "body{margin:0}" > styles.css
git add . && git commit -q -m "feat: filter and styles"

# 2. A dirty state before the reset
echo "// UNCOMMITTED WORK" >> app.js
echo "private notes" > notes.txt
git status --short
 M app.js
?? notes.txt
# 3. --soft
git reset --soft HEAD~1
git log --oneline -1
git status --short
b52c9d1 feat: list
M  app.js
A  styles.css
?? notes.txt

The commit has come off the branch and its content is staged (M and A in the first column). And there is a fine detail here: app.js appears only once because the index contains both the undone commit's content and your uncommitted modification still outside... in reality the modification of app.js is still in the working directory, and the index has the version from the undone commit. Check it:

git diff --cached --stat     # the undone commit's content, staged
git diff --stat              # your uncommitted modification, still pending
 app.js     | 1 +
 styles.css | 1 +
 app.js | 1 +

Nothing has been lost: the two layers are intact and separate.

# 4. Undo with ORIG_HEAD
git reset --hard ORIG_HEAD
git log --oneline -1
git status --short
grep -c "UNCOMMITTED WORK" app.js
7d3a8f4 feat: filter and styles
?? notes.txt
0

The branch has gone back to its place, but the modification of app.js has disappeared: --hard has overwritten it. ORIG_HEAD restores the branch's position, not your work in progress. It is exactly the warning from section 8.

# 5. The complete table (recreating the dirty state each time)
Command git log -1 Modification of app.js notes.txt
git reset --soft HEAD~1 b52c9d1 Kept (on disk) Untouched
git reset --mixed HEAD~1 b52c9d1 Kept (mixed with the commit's content) Untouched
git reset --hard HEAD~1 b52c9d1 LOST Untouched
# 6. Why notes.txt survives
git ls-files | grep notes
(no output)

notes.txt is not in the index: Git does not track it. reset operates on HEAD, the index and the files Git knows about; an untracked file is invisible to it. To delete it you need git clean (section 7).

Solution 2:

mkdir /tmp/p9-02-b && cd /tmp/p9-02-b && git init -q -b main
echo "v1" > app.js; echo "v1" > styles.css; echo "v1" > index.html
git add . && git commit -q -m "c1"
echo "v2" > app.js && git commit -q -am "c2"
echo "v3" > app.js; echo "v3" > styles.css; echo "v3" > index.html
git add . && git commit -q -m "c3: touches all three files"
echo "v4" > app.js && git commit -q -am "c4"
# 1. The error
git reset --hard HEAD~1 -- app.js
fatal: Cannot do hard reset with paths.

With a path, reset never touches the working directory or moves the branch; --hard implies both things, so it is contradictory. Git rejects it instead of doing something halfway.

# 2. The correct form
git reset HEAD~2 -- styles.css      # HEAD~2 = c2, that is, before c3
git status --short
M  styles.css
git diff --cached styles.css        # index vs HEAD: there is indeed a change
git diff styles.css                 # working tree vs index: also, in the opposite direction
cat styles.css
-v3
+v1
v3

The key point: the file on disk is still v3. Only the index has v1. That is why git diff (working tree vs index) shows the inverse change. And the branch has not moved:

git log --oneline -1
9c4e7b2 c4
# 3. Commit only that part
git commit -q -m "partial revert: return styles.css to its state before c3"
cat app.js styles.css index.html
v4
v1
v3

app.js keeps v4, index.html keeps v3 (what c3 contributed), and only styles.css has gone back. A third of a commit has been undone.

Note, though: the file on disk was still v3 when you committed. The commit says v1 and the disk says v3, so now there is a pending modification. That asymmetry is precisely what restore avoids:

# 4. The modern form, which leaves disk and index consistent
git restore --source=HEAD~3 --staged --worktree styles.css
git status --short
cat styles.css
M  styles.css
v1

The index and the working directory agree. That is the practical difference: reset -- <path> only stages; restore --staged --worktree stages and writes to disk. For everyday use, restore is more predictable.

Solution 3:

mkdir /tmp/p9-02-c && cd /tmp/p9-02-c && git init -q -b main
echo "base" > app.js && git add . && git commit -q -m "c1"

# 1. The three situations
echo "UNPUBLISHED COMMIT" >> app.js && git commit -q -am "c2: committed work"
echo "STAGED CHANGE" >> styles.css && git add styles.css
echo "CHANGE ON DISK ONLY" >> app.js
git status --short
A  styles.css
 M app.js
# 2. The disaster
git reset --hard HEAD~1
git status --short           # (no output)
cat app.js
ls
base
app.js

Everything gone: the commit, the staged file and the modification on disk.

# 3. The commit comes back
git reflog -3
3f8a1d6 HEAD@{0}: reset: moving to HEAD~1
8f4c2a9 HEAD@{1}: commit: c2: committed work
3f8a1d6 HEAD@{2}: commit (initial): c1
git branch rescue 8f4c2a9
git show rescue --stat
 app.js | 1 +

Recovered in full.

# 4. The staged change too
git fsck --lost-found
dangling blob 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b
git cat-file -p 6f2b9d4
STAGED CHANGE
git cat-file -p 6f2b9d4 > styles.css     # recovered

The git add saved it. On staging the file, Git created the blob and wrote it into .git/objects. The reset --hard removed the reference from the index, but the object stayed.

# 5. The change that was on disk only
git fsck --lost-found | wc -l
grep -c "ON DISK ONLY" $(git rev-list --objects --all | awk '{print $1}' > /dev/null; echo app.js)
1
0

There is only one dangling object, the one from the add. The line CHANGE ON DISK ONLY does not exist anywhere in .git. Its hash was never calculated, it was never compressed, it was never written. Git cannot recover something it never knew about. The only real avenues would be your editor's local history (VS Code, IntelliJ and Vim with undofile keep it) or a filesystem backup.

# 6. With a stash beforehand, everything changes
cd /tmp && rm -rf p9-02-d && mkdir p9-02-d && cd p9-02-d && git init -q -b main
echo "base" > app.js && git add . && git commit -q -m "c1"
echo "UNPUBLISHED COMMIT" >> app.js && git commit -q -am "c2"
echo "STAGED CHANGE" >> styles.css && git add styles.css
echo "CHANGE ON DISK ONLY" >> app.js
echo "new file not added" > draft.txt

git stash push -u -m "just in case"
git reset --hard HEAD~1
git stash list
stash@{0}: On main: just in case
git stash pop
git status --short
cat app.js | tail -1
ls
A  styles.css
 M app.js
?? draft.txt
CHANGE ON DISK ONLY
app.js  draft.txt  styles.css

Everything intact, including the untracked file, thanks to -u. The stash turned the three layers into hidden commits inside the database, and therefore into something recoverable even if the stash had been deleted (lesson 09-04).

One second of git stash push -u buys the difference between "I have lost two hours" and "nothing happened".

Conclusion

reset stops being frightening as soon as you understand that it does two things under the same name.

  • git reset <commit> moves the branch. It does not delete commits: it rewrites a 41-byte file. The commits that stop being reachable remain intact in the database.
  • The three modes are distinguished by how far they reach: --soft only moves HEAD; --mixed (the default) also rewrites the index; --hard also overwrites the working directory. Only --hard destroys anything, and what it destroys is uncommitted changes, not commits. It does not touch untracked files.
  • --keep is the prudent --hard: it aborts instead of trampling your local work.
  • git reset <commit> -- <path> is a different operation: it never moves the branch, it never touches the disk, it only fills the index. That is why --hard with paths is an error.
  • For everyday work, git restore: --staged to unstage (it loses nothing), with no options to discard on disk (it destroys), --source to bring content from another commit and -p to do it hunk by hunk.
  • git clean is the only command in this module with no safety net, because what was never committed is not in .git/objects. -n before -f, always; -x deserves reading twice; git stash push -u turns it all into something recoverable.
  • ORIG_HEAD undoes the last reset, merge, rebase or pull. It only saves one position.
  • And the decision, in one question: is it published? If not, reset, --amend or rebase -i. If it is and the branch is shared, revert (lesson 05-06), no exceptions.

That leaves the case the decision table dispatches with a "see 09-03": what to do when the thing that has got tangled is not inside your repository, but between your repository and the server. The rejected push, the divergent branches message, the colleague who rewrote the published history and left you with work hanging off a base that no longer exists.

All of that, and the closing of the promise we left open in lesson 04-05, in lesson 09-03: Resolving Divergence with the Remote.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved