Git reset hard: what it throws away before you run it
git reset --hard has a reputation it half deserves. It does destroy work, but not the work most people expect, and it leaves untouched several things people assume it wipes. The gap between those two lists is where accidents happen: someone runs it to clean up a directory, the directory is still full of junk afterwards, and the one file they had spent an hour on is gone. Reading the command as three separate effects, on three separate places, makes it predictable.
The three places a reset can touch
Every form of git reset moves the current branch head. What differs between modes is how far the damage travels beyond that.
Git holds your work in three layers. HEAD is the commit the branch currently points at. The index is the staged snapshot that will become the next commit. The working tree is the files on disk. --soft moves only the first. --mixed, the default, moves the first two. --hard moves all three.
The manual's wording for --hard is worth reading literally, because both halves matter:
Resets the index and working tree. Any changes to tracked files in the working tree since the named commit are discarded. Any untracked files or directories in the way of writing any tracked files are simply deleted.
Two things follow from that sentence. Changes to files Git already knows about are gone, staged or not. And untracked files are only removed when they physically block a tracked file from being written, which is a much narrower case than "the command cleans the folder".
One safety net is created for you without being asked. Before the operation, Git sets ORIG_HEAD to the tip of the current branch. git reset --hard with no commit argument is a synonym for git reset --hard HEAD, which throws away local modifications while leaving the branch where it is.
The five modes, side by side
The mode is not a detail. Picking the wrong one is the difference between undoing a commit and undoing an afternoon.
| Mode | Branch head | Index | Working tree | Typical use |
|---|---|---|---|---|
--soft |
Moves | Untouched | Untouched | Redo the last commit with a better message or a wider scope |
--mixed (default) |
Moves | Reset | Untouched | Unstage everything, keep the edits, commit them differently |
--hard |
Moves | Reset | Reset | Get back to a known commit and abandon everything since |
--merge |
Moves | Reset | Partially updated | Back out a merge while keeping unstaged local work |
--keep |
Moves | Reset | Partially updated | Move the branch without disturbing local edits |
--merge updates the files that differ between the target commit and HEAD, but keeps the ones whose changes exist only between the index and the working tree. If a file that differs between the target and the index has unstaged changes, the reset aborts rather than picking a winner.
--keep is stricter in a different direction. It resets index entries and updates files that differ between the target and HEAD, and aborts if any of those files has local changes.
Both of those refusals are the feature. When the intent is to move the branch without losing local edits, one of them declining is a much better outcome than --hard succeeding. The full option list is in the git reset documentation.
What actually gets destroyed
Three categories of work are at risk, and only three.
Uncommitted changes to tracked files
This is the common loss. Anything edited since the target commit disappears, whether it was staged or not, because --hard resets both the index and the working tree. There is no reflog for the working tree. A file that was never committed and never stashed has no object in the repository to recover, so no command brings it back.
Untracked files standing in the way
A file Git does not track survives a hard reset, with one exception: if a tracked file needs to be written at that exact path, the untracked file or directory there is deleted. This is how a generated file that someone later committed upstream quietly eats a local version of itself.
Commits that existed only on this branch
Commits are safer than they feel. Moving the branch backwards makes them unreachable, not deleted. They stay in the object database and in the reflog until garbage collection is allowed to remove them, which does not happen immediately.
Notice what is missing from the list. Stash entries are untouched, because the newest stash lives in refs/stash and older ones in that reference's reflog, all of which are reachable. Other branches and tags are untouched. Ignored build output is untouched unless it blocks a tracked path. And untracked files in general are not what this command exists to remove. That job belongs to git clean, which refuses to delete anything without -f unless clean.requireForce has been turned off, needs -d to descend into untracked directories, and needs -x before it will touch ignored files.
git clean -n -d
git clean -n -d -x
Running the dry run first is the habit worth building. It prints exactly what would be removed and deletes nothing.
Check what will be lost before you press return
Everything above is knowable in advance, in about fifteen seconds, with three commands that change nothing.
git status --short
git diff --stat
git diff --cached --stat
In the short format each path carries a two letter code. Outside a merge, the first letter is the state of the index and the second is the state of the working tree, and ?? marks an untracked path. Ignored files are hidden unless --ignored is passed, in which case they appear as !!. So one screen tells you which files have staged changes, which have unstaged changes, and which Git has never heard of.
The two diffs split the same information by layer. Plain git diff compares the working tree against the index, so it shows the edits that are not staged. git diff --cached compares the index against HEAD, so it shows what is staged. A hard reset discards both lists in full. Anything appearing in either one is work that needs a commit or a stash first.
For the commits rather than the files, list what is about to become unreachable.
git log --oneline HEAD~3..HEAD
git stash list
Reading that output before the reset is also what makes the recovery possible afterwards, because a subject line you have seen once is enough to recognise the right entry in git reflog later. Adding --ignored to the status call is worth it on a Mac, where the interesting untracked files are often the ones a tool put there.
Getting back what the reflog still remembers
After a hard reset that went too far, the recovery path starts with the reflog rather than with panic.
git reflog
git reset --hard HEAD@{1}
HEAD@{1} is where HEAD pointed one change ago. ORIG_HEAD refers to the tip of the branch as it was immediately before the reset, which is the same thing for a single mistake and clearer to read.
The reflog is not permanent, and knowing the two clocks matters. Reachable entries are pruned once they are older than gc.reflogExpire, which defaults to 90 days. Entries that are not reachable from the current tip are pruned after gc.reflogExpireUnreachable, which defaults to 30 days. That second number is the one that applies to commits you just made unreachable.
When a commit has fallen out of the reflog but garbage collection has not run, the objects may still be sitting in the repository. git fsck finds them.
git fsck --lost-found
That writes dangling objects into .git/lost-found/commit/ or .git/lost-found/other/, and for a blob it writes the file contents rather than the object name, so a lost file can be read directly. git fsck --unreachable lists objects that exist but are reachable from nothing.
There is a grace period on the destruction too. git gc prunes loose objects older than two weeks by default, overridable through gc.pruneExpire. The practical reading: a commit orphaned this morning is almost certainly still on disk this evening, and a file that was never committed never was.
Commands that do the job without the collateral damage
Most uses of git reset --hard are one of four intents, and each has a narrower command that cannot take the rest of the directory with it.
To unstage without touching the files, name a path. git reset <pathspec> resets index entries for matching paths to their state at the target and does not affect the working tree or the branch. It is the exact opposite of git add <pathspec>, and git restore --staged <pathspec> is the modern spelling of the same operation. For part of a file, git reset -p is the opposite of git add -p.
To throw away edits in one file rather than all of them, restore that file.
git restore src/config.ts
git restore --source=HEAD~2 src/config.ts
To redo the last commit, move the head and keep everything else.
git reset --soft HEAD^
git commit -c ORIG_HEAD
To get to a clean tree without losing the edits, stash them instead of deleting them. A stash entry costs nothing and can be inspected, applied on another branch, or dropped later.
git stash push -u -m "before reset"
And before any hard reset of commits, spend one command on a label. A branch or tag pointing at the old tip makes the commits reachable, which takes them out of the reach of expiry entirely.
git branch wip/before-reset
git reset --hard HEAD~3
If the commits were already pushed, a hard reset is the wrong tool regardless. Rewinding a shared branch forces everyone who fetched it to repair their own copy. Record a new commit that undoes the change instead, and if a rewrite is genuinely required, git push --force-with-lease refuses when the remote has moved since your last fetch, which plain --force happily overwrites.
Before you run it on a Mac
Two local details change the odds.
Finder writes .DS_Store files into folders as they are browsed, and Xcode, node tooling and build systems scatter generated files beside source. All of that is untracked, so a hard reset leaves it in place. Expecting the command to produce a pristine checkout and then building on top of stale artefacts is a routine way to lose an hour to a phantom bug. git clean -n -d -x tells you what is really there first.
APFS is case-insensitive in its default configuration, and git clone and git init probe for that and set core.ignoreCase accordingly. A hard reset to a commit where a file was renamed by capitalisation alone can therefore leave the working tree in a state the same reset would not produce on a case-sensitive filesystem.
Both of these are easier to see when the folder listing and the command line describe the same directory at the same moment, which is the argument for a file manager with a built-in terminal rather than switching windows to check. What such a tool does and does not include is laid out in the comparison with other file managers.
What to change first
Make git stash push -u the reflex instead of git reset --hard, and keep --hard for the case where the target is a commit and the tree is already clean. Before any reset that moves the branch, spend one command on git branch wip/<something> so the old tip stays reachable. If checking what is actually in a folder means leaving the terminal, Atriens puts both in the same window.
Frequently asked questions
Does git reset hard delete untracked files?
Only the ones in the way. Untracked files or directories that block a tracked file from being written at the same path are deleted; everything else untracked stays exactly where it was. Removing untracked files is the job of git clean, which needs -f to act, -d to enter untracked directories and -x to include ignored files.
Can a commit be recovered after git reset --hard?
Usually yes, for a while. The commit becomes unreachable rather than deleted, so git reflog still lists it and git reset --hard HEAD@{1} puts the branch back. Unreachable reflog entries expire after 30 days by default and loose objects are pruned after two weeks of grace, so recovery gets less likely the longer it waits.
Can uncommitted changes be recovered after git reset --hard?
No. Working tree edits that were never committed and never stashed have no object stored in the repository, so there is nothing for the reflog or git fsck to find. This is the one genuinely irreversible part of the command, and it is the reason to stash before resetting.
What is the difference between git reset --hard and git checkout or git restore?
git reset --hard moves the branch head and rewrites both the index and the working tree to match a commit. git restore changes files only, leaving the branch and its history alone, and it accepts a path so the blast radius is one file instead of the whole tree. When the goal is to discard an edit rather than a commit, git restore is the correct command.
Is git reset --hard safe on a branch that has been pushed?
It rewrites history that other people have already fetched, so it is not. Their next fetch will show the branch diverging, and anyone who built on the old commits has to repair their own copy. Record a revert commit instead, and where a rewrite is unavoidable use git push --force-with-lease, which fails if the remote has moved since the last fetch.