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
- The three areas, once again
git resetmoves the branch: the central idea- The three modes:
--soft,--mixed,--hard - Cases arranged by what you want to undo
git reset <commit> -- <path>: the other formgit restore: the modern command for everyday workgit clean: the one that really does destroyORIG_HEAD: the immediate safety net- The decision table
- 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.
git reset moves the branch: the central idea
git reset moves the branch: the central ideaBefore 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 sinceHEADpoints at the branch,HEADmoves 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:
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.
- The three modes:
--soft, --mixed, --hard
--soft, --mixed, --hardThe 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
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
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
Everything clean, as if that commit had never existed. And here lies the danger, which is worth stating precisely:
--harddoes 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
| 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:
--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:
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.
- 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.
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, equivalentYou have lost nothing: the file is still modified in the working directory, it merely stops being staged.
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.
You have lost nothing. The commit is no longer on the branch, but all its content is staged. Now she can redo it:
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
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:
Remember that the hash changes: if it was published, this rewrites history.
4.5. Throw away the last N commits
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-241With 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 b52c9d14.6. Return the branch to exactly how it is on the remote
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 changesIf 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.
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.
git reset <commit> -- <path>: the other form
git reset <commit> -- <path>: the other formHere 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.
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 --shortThe 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:
The "that file should not be in the repository" case
Picking up from lesson 08-03:
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.
git restore: the modern command for everyday work
git restore: the modern command for everyday workGit 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.jsThe 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".
@@ -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.
git clean: the one that really does destroy
git clean: the one that really does destroyEverything 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
-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
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> 4Option 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":
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
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.
ORIG_HEAD: the immediate safety net
ORIG_HEAD: the immediate safety netBefore an operation that moves HEAD substantially — reset, merge, rebase, pull — Git saves the previous position in a special reference:
So the immediate "undo" of a reset is:
Or, following the safe pattern:
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_HEADis 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.
- 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 <f><br/>(or git clean if it is untracked)"]
Q2 -->|Yes| R2["git restore --staged <f>"]
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
- Create a repository with
app.jsand three commits. In the third one, also addstyles.css. - Modify
app.jswithout committing and create a new filenotes.txtwithout adding it. - Run
git reset --soft HEAD~1and note down the output ofgit status --shortandgit log --oneline -1. - Undo it with
git reset --hard ORIG_HEAD... and observe what has happened to your modification ofapp.js. Explain it. - Repeat the complete experiment with
--mixedand with--hard, and fill in a table with three columns:log -1, the uncommitted change inapp.js, andnotes.txt. - Explain why
notes.txtsurvives even--hard.
Exercise 2: reset with a path versus reset without one
- On a repository with four commits, where the third modified
app.js,styles.cssandindex.html, trygit reset --hard HEAD~1 -- app.jsand explain the error. - Use
git reset HEAD~2 -- styles.cssand check withgit status,git diffandgit diff --cachedwhich area the change has ended up in. - Commit. Check that you have undone only the
styles.csspart of the third commit and that the rest is still in the project. - Achieve the same result with
git restore --source=... --staged --worktreeand compare the state of the working directory in both cases.
Exercise 3: the real limit of recovery
- In a test repository, create three situations at once: an unpublished commit, a change staged with
git add, and a change on disk only. - Run
git reset --hard HEAD~1. - Recover the commit using the reflog.
- Try to recover the staged change with
git fsck --lost-foundandgit cat-file -p. - Try to recover the change that was on disk only. Document why it is not possible.
- Repeat the whole exercise having run
git stash push -ubeforehand 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 --shortThe 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 pendingNothing 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.jsThe 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.
| 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 |
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"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 --shortgit 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.cssThe 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:
# 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.htmlapp.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.cssThe 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 --shortEverything gone: the commit, the staged file and the modification on disk.
3f8a1d6 HEAD@{0}: reset: moving to HEAD~1
8f4c2a9 HEAD@{1}: commit: c2: committed work
3f8a1d6 HEAD@{2}: commit (initial): c1Recovered in full.
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)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 listEverything 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:
--softonly movesHEAD;--mixed(the default) also rewrites the index;--hardalso overwrites the working directory. Only--harddestroys anything, and what it destroys is uncommitted changes, not commits. It does not touch untracked files. --keepis 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--hardwith paths is an error.- For everyday work,
git restore:--stagedto unstage (it loses nothing), with no options to discard on disk (it destroys),--sourceto bring content from another commit and-pto do it hunk by hunk. git cleanis the only command in this module with no safety net, because what was never committed is not in.git/objects.-nbefore-f, always;-xdeserves reading twice;git stash push -uturns it all into something recoverable.ORIG_HEADundoes the lastreset,merge,rebaseorpull. It only saves one position.- And the decision, in one question: is it published? If not,
reset,--amendorrebase -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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
