git reset (Soft, Mixed, Hard)

git reset is one of Git’s most powerful commands for undoing work, and also one of the easiest to misuse. It moves your current branch backward (or sideways) to a different commit, and depending on which mode you choose, it can leave your changes neatly staged, quietly unstaged, or erased from your working files entirely. Before you run git reset against anything you care about, you need to understand exactly what each mode does to your commit history, your staging area, and your working directory.

Overview: How git reset Works

To understand git reset, you first need to understand the three things Git juggles at all times: the commit history (a chain of commit objects, each pointing to a snapshot called a tree, which in turn points to blobs holding file contents), the index (also called the staging area, a snapshot of what will go into your next commit), and the working directory (the actual files on disk that you edit). A branch like main is nothing more than a lightweight, movable pointer to one specific commit, and HEAD normally points to that branch (so it moves along automatically as the branch moves).

When you run git reset <commit>, Git moves the current branch pointer (and HEAD with it) to point at <commit> instead of wherever it was. What else happens depends entirely on the mode:

  • --soft: Only the branch pointer moves. The index and working directory are left completely untouched. Anything that was committed in the commits you just \”undid\” now shows up as already staged, ready to be committed again (possibly reshaped).
  • --mixed (the default when you omit a flag): The branch pointer moves, and the index is reset to match the target commit’s tree. The working directory is left untouched. This effectively un-stages everything from the commits you undid; your edits are still on disk, but Git now sees them as unstaged modifications.
  • --hard: The branch pointer moves, the index is reset, and the working directory is forcibly overwritten to match the target commit exactly. Any staged or unstaged local changes to tracked files are gone. This is destructive and should be used with real caution.

You can also give git reset a path instead of relying on the branch-moving behavior: git reset -- <path> only touches the index for that path, unstaging it without moving HEAD or touching the working file. In modern Git, the more explicit git restore --staged <path> does the same job and is usually clearer intent, but you will see the older git reset <path> form constantly in the wild and in older tutorials.

reset vs. revert vs. restore

git reset rewrites which commit your branch points at, which is only safe for commits that exist solely on your local, unshared branch. git revert instead creates a brand-new commit that undoes the changes of an earlier one, leaving history intact and moving forward, which makes it the correct tool once a commit has been pushed and others may have it. git restore is the modern, narrower command for discarding working-tree edits or unstaging files, without any of reset‘s branch-pointer-moving behavior.

Syntax

git reset [--soft | --mixed | --hard] \"<commit>\"
git reset \"<commit>\" -- \"<pathspec>\"
Flag Branch / HEAD pointer Index (staging area) Working directory
--soft Moved Unchanged Unchanged
--mixed (default) Moved Reset to match target commit Unchanged
--hard Moved Reset to match target commit Reset to match target commit (destructive)
  • <commit> — any commit reference: a full or abbreviated SHA, a branch name, HEAD~1 (one commit before the current one), HEAD~3, or a remote-tracking ref like origin/main. If omitted, Git assumes HEAD, which is only useful with a pathspec (for unstaging).
  • — <pathspec> — restrict the reset to specific files; this never moves the branch pointer, it only updates the index entries for those paths.

Examples

Example 1: Undo the last commit but keep the changes staged (–soft)

git log --oneline -3
git reset --soft HEAD~1
git status

Output:

a1b2c3d (HEAD -> main) fix: correct button alignment on login page
9f8e7d6 feat: add login form and validation
5c4b3a2 chore: initial project scaffold

On branch main
Changes to be committed:
  (use \"git restore --staged <file>...\" to unstage)
	modified:   src/components/LoginForm.jsx

Here HEAD~1 means \”the commit right before the current one.\” After the --soft reset, the main branch pointer moved back to 9f8e7d6, but nothing else changed: the file edits that were captured in the undone commit a1b2c3d reappear as already-staged changes, exactly as if you had just run git add. This is handy for fixing a commit message, splitting a commit, or folding it into the next one.

Example 2: Undo a commit and unstage everything (–mixed, the default)

git add .
git commit -m \"feat: add login form and fix typo in README\"
git reset HEAD~1
git status

Output:

On branch main
Changes not staged for commit:
  (use \"git add <file>...\" to update what will be committed)
  (use \"git restore <file>...\" to discard changes in working directory)
	modified:   src/components/LoginForm.jsx
	modified:   README.md

Because no flag was given, Git used the default --mixed mode. The branch pointer moved back one commit and the index was reset to match it, so both files are now unstaged even though their edits still sit on disk. This is useful when a commit accidentally bundled unrelated changes together: you can now stage and commit src/components/LoginForm.jsx and README.md as two separate, focused commits.

Example 3: Hard reset a local branch to match origin/main

git fetch origin
git reset --hard origin/main
git status

Output:

HEAD is now at 3e9f1a2 fix: patch security issue in auth middleware
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

Here the local main branch had accumulated a handful of throwaway experimental commits. git fetch origin downloads the latest history from the remote without touching any local branches, and git reset --hard origin/main then forcibly rewinds (or fast-forwards) the local main pointer, the index, and every tracked file in the working directory to exactly match origin/main. The experimental commits become unreachable from any branch; they are not instantly deleted, but they are gone from your visible history and any uncommitted edits you had are lost. Only do this when you are certain you don’t need the local commits.

How It Works Step by Step

Internally, git reset <commit> performs these steps in order:

  • Git resolves <commit> to a concrete commit object (following branch names, HEAD~N, or remote-tracking refs down to a SHA).
  • The current branch’s ref file is updated to point at that commit’s SHA. Since HEAD normally points at the branch (not directly at a commit), HEAD effectively moves too.
  • With --mixed or --hard, Git reads the target commit’s tree and rewrites the index so every staged entry matches that tree exactly — this is the same tree-reading machinery used when checking out a commit.
  • With --hard only, Git also walks the working directory and overwrites every tracked file to match the target tree, and removes tracked files that don’t exist in that tree. Crucially, git reset --hard does not touch untracked files — for that you need git clean.
  • Commits that are no longer reachable from any branch, tag, or other ref become \”dangling\” — they are not immediately deleted. Git’s reflog keeps a record of where HEAD has been (by default for around 90 days), so a reset you regret can often be undone with git reflog to find the old SHA, followed by another reset back to it.
git reflog
git reset --hard HEAD@{1}

The command above inspects the reflog to find the position HEAD was at one step ago, then hard-resets back to it — this is the standard safety net after an accidental --hard reset.

Common Mistakes

Mistake 1: Losing uncommitted work with –hard

git commit -am \"wip\"
git reset --hard HEAD~1

If there were additional uncommitted edits sitting in the working directory before this reset, --hard destroys them along with rewinding the commit — there is no confirmation prompt. Always run git status first, and if there is anything you might want later, stash it:

git stash
git reset --hard HEAD~1
git stash pop

Mistake 2: Resetting and force-pushing a shared branch

git reset --hard HEAD~3
git push --force origin main

This rewrites main‘s history and overwrites the shared remote branch. Anyone who already pulled the old commits now has a branch that has diverged from the remote; when they pull again, they can get confusing merge results or, worse, lose track of commits entirely. This is Git’s golden rule of history rewriting: never reset (or rebase) commits that other people have already pulled or built on. If a shared commit needs undoing, use git revert instead, which adds a new, safe commit rather than erasing history:

git revert HEAD~2..HEAD
git push origin main

If you absolutely must rewrite a shared branch (after coordinating with your team), prefer git push --force-with-lease over a bare --force, since it fails safely if the remote has commits you haven’t seen yet.

Best Practices

  • Run git status before any --hard reset so you know exactly what you’re about to lose.
  • Stash uncommitted work (git stash) before a risky reset if there’s any chance you’ll want it back.
  • Only use git reset on commits that exist purely on your own local, unpushed branch; use git revert for anything already shared.
  • Remember git reflog is your safety net — a \”lost\” hard reset is almost always recoverable within the reflog’s retention window.
  • Prefer the narrower git restore and git restore --staged for simple \”discard my edits\” or \”unstage this file\” tasks; reach for git reset when you actually need to move the branch pointer.
  • Use git reset --soft HEAD~1 as a quick way to reopen the last commit for editing, splitting, or squashing before recommitting with a proper Conventional Commits message.
  • Before resetting a local branch to match a remote, run git fetch first so you’re comparing against the remote’s true current state, not a stale copy.

Practice Exercises

  • Exercise 1: Make a single commit that bundles two unrelated changes (for example, a new feature file and an unrelated README fix). Use a --soft reset to uncommit it, then stage and commit each change separately with its own Conventional Commits message.
  • Exercise 2: Accidentally commit a generated folder (like dist/ or node_modules/) along with real source changes. Undo the commit with a mixed reset, add the folder to .gitignore, unstage it, and recommit only the intended files. Expected end state: git status shows the generated folder as ignored, not tracked.
  • Exercise 3: Create three throwaway commits on your local main that you decide you don’t want. Fetch from origin and hard-reset local main to exactly match origin/main. Expected end state: git log --oneline -1 on your local branch shows the same commit SHA as origin/main, and git status reports a clean working tree.

Summary

  • git reset <commit> moves the current branch pointer (and HEAD) to a different commit; what else changes depends on the mode.
  • --soft moves only the branch pointer — your changes stay staged.
  • --mixed (the default) moves the pointer and resets the index — your changes become unstaged but stay on disk.
  • --hard moves the pointer, resets the index, and overwrites the working directory — this can permanently discard uncommitted work.
  • git reset --hard never touches untracked files; use git clean for those.
  • Commits left behind by a reset aren’t deleted immediately — git reflog can recover them.
  • Never reset commits that have already been shared with others; use git revert for public history instead.
  • For simple unstage/discard tasks, git restore and git restore --staged are often clearer than reaching for git reset.