Skip to content

Undoing changes and commits in Git

docs.scrimba.com

Something always needs undoing. You typed an edit you did not mean, tried a fix that made things worse, or committed something you would rather nobody see. Git keeps a record of nearly every version of your project it has ever seen, so most mistakes are more reversible than they feel in the moment. Which tool gets you back depends on how far the mistake has traveled: still sitting in your editor, already staged, already committed, or already pushed somewhere a teammate can see it. This chapter walks through each stage in that order.

What are push and pull?

Pushing sends your commits to a shared copy of the repository that teammates also work from, and pulling brings their commits down to you. Remotes and GitHub covers both in full; for this chapter, "pushed" means the commit has left your machine.

Discarding a change before you commit it

Say you are still restyling styles.css in weather-app from the commit loop, and the new layout turns out to be a bad idea. You have not staged it, you have not committed it. You want the file back the way it last looked, nothing more. The command for exactly that job is git restore:

bash
$ git status
On branch main
Changes not staged for commit:
        modified:   styles.css

$ git restore styles.css

$ git status
On branch main
nothing to commit, working tree clean

git restore <file> throws away an uncommitted edit and puts the file back to its last saved state. If you had already staged the change with git add before changing your mind, plain git restore will not touch it, since the file is now sitting in the staging area rather than only in your working directory. Unstage it first, then restore it:

bash
$ git add styles.css
$ git restore --staged styles.css
$ git restore styles.css

Two separate moves for two separate areas: git restore --staged moves a file back out of the staging area, and git restore on its own discards the edit in your working directory. git restore only reaches uncommitted work, once a change is committed, a different command takes over.

Older tutorials and scripts use git checkout -- <file> for the exact job git restore <file> does today. checkout used to double as both "switch branches" and "restore a file", which was confusing enough that Git split the two into switch and restore. You will still meet checkout in older material, recognize it, but reach for restore in anything you write now. You can also discard every unstaged change in the current folder at once with git restore ., which is worth a pause before running, since it clears every file in one go.

restore's undo stops firmly at the working directory. A commit is a saved object that sticks around until Git's garbage collection removes it, which is why a bad commit is recoverable through the reflog even after a hard reset. An uncommitted edit was never turned into a saved object at all: restore checks the last saved version of a file back out over your edit, and there is no earlier state stored anywhere to dig back out of. That is the practical case for committing early and often. A messy commit is almost always fixable; an edit you never saved usually is not.

JunoDiscarding an uncommitted changegit restore <file> throws away an edit you have not committed and puts the file back the way it last was. If you already staged the change with git add, unstage it first with git restore --staged <file>, then restore the file if you want the edit gone too. This only works on changes you have not committed yet, so it cannot rescue a mistake you already saved.
JunoDiscarding an uncommitted changegit restore <file> is the modern replacement for git checkout -- <file>, and git restore . clears every unstaged change in the folder at once, so run git status first to see what that would touch. You will still see checkout doing this job in older answers and scripts, it is the same idea under an older, more overloaded name.
JunoDiscarding an uncommitted changerestore never creates or touches a commit, it only overwrites your working directory with the last saved version of a file. That means it leaves no trail to recover from if you are wrong, unlike almost everything else in this chapter. Commit small and commit often, and "oops" turns into a problem reset or the reflog can fix instead of a change that never had a save point to return to.

Setting work aside with git stash

Sometimes there is no mistake to fix, only bad timing. You are mid-edit on styles.css and a teammate needs you on a different branch right now, but you are not ready to commit half-finished styling. git stash shelves your changes and hands you a clean working directory so you can step away and come back later.

bash
$ git status
On branch main
Changes not staged for commit:
        modified:   styles.css

$ git stash
Saved working directory and index state WIP on main: 8f3c7d1 Cache forecast responses in localStorage

$ git status
On branch main
nothing to commit, working tree clean

The edit to styles.css is not gone, it is parked. Bring it back with git stash pop once you are ready:

bash
$ git stash pop
On branch main
Changes not staged for commit:
        modified:   styles.css

git stash is for changes you are not ready to commit yet, it never creates a commit on your branch.

A single stash rarely covers a real week of work, so know the rest of the toolkit. git stash list shows everything you have shelved, most recent first. git stash pop reapplies the top entry and removes it from the list; git stash apply reapplies it but leaves it in the list, useful if you want the same shelved change on two branches. Name a stash on the way in, so list is not a wall of unlabeled entries later:

bash
$ git stash push -m "wip: forecast card layout"
$ git stash list
stash@{0}: On main: wip: forecast card layout

A stash is built from the same commit machinery as everything else in Git. git stash saves your staged and unstaged state as a couple of commit objects and points a special reference, refs/stash, at them, which is why git stash list reads like a small history of its own. The reflog relies on this same trick, a reference pointing at commits that no branch reaches.

JunoSetting work asidegit stash shelves changes you are not ready to commit, so your working directory goes clean, and git stash pop brings them back later. Nothing gets committed and nothing gets lost, the edit sits parked for a bit. I reach for this constantly whenever a teammate needs me on another branch right now.
JunoSetting work asidegit stash list shows every shelved change, pop reapplies and removes the most recent one, apply reapplies without removing it. Name stashes on the way in with push -m, a stash list full of unlabeled entries a week later helps nobody, including you.
JunoSetting work aside A stash is a couple of commit objects that refs/stash points at, sitting off any branch, which is why it behaves like its own tiny history. Once you see it that way, stash stops feeling like magic, it is the same object model doing the same trick again.

Undoing a commit with git revert

Say the commit 8f3c7d1, "Cache forecast responses in localStorage", turns out to be wrong, and it is already pushed, so a teammate has pulled it too. Rewriting that commit now would change something someone else already has, and their copy and yours would disagree the next time they pull. git revert solves this without changing anything that already exists: it creates a brand new commit that applies the opposite of the change.

bash
$ git revert 8f3c7d1
[main 2b6f0d1] Revert "Cache forecast responses in localStorage"

History still shows both commits, the original and the one undoing it, in order. Nobody's copy needs reshaping, a teammate pulls the new revert commit like any other.

git revert is always safe on a commit others already have, because it adds a new commit instead of changing an old one.

Reverting a change that overlaps with later commits can produce a conflict, the same kind merging and conflicts covers: Git marks the file with <<<<<<<, =======, and >>>>>>> and waits for you to resolve it. Fix the markers, git add the file, then finish with git revert --continue.

bash
$ git revert 8f3c7d1
Auto-merging src/index.js
CONFLICT (content): Merge conflict in src/index.js

$ git add src/index.js
$ git revert --continue

Revert's safety on shared history rests on the same idea behind never rebasing a public branch: rewriting a commit that others already pulled forces their copy and yours out of sync, and somebody has to force their way past the mismatch afterward. Revert leaves every existing commit exactly as it was. It only adds a new one, so nobody's copy needs reconciling with yours.

JunoUndoing a commit with revertgit revert <commit> undoes a commit by adding a brand new commit that reverses it, instead of erasing the original. That makes it the safe pick once a commit is already shared with anyone else, since nothing already saved gets changed. History keeps a record of both the mistake and the fix, which I like more the longer I use Git.
JunoUndoing a commit with revert Revert can conflict the same way a merge does when later commits touched the same lines, resolve it the same way: fix the markers, add the file, then git revert --continue. Reach for it any time the commit you're undoing is already pushed or shared.
JunoUndoing a commit with revert Revert never touches an existing commit, so it is the undo tool that plays nicely with a branch other people are already pulling from. Keep it as your default the moment a bad commit has left your own machine.

Rewinding with git reset: soft, mixed, and hard

There is a third way to undo a commit, git reset, and it works differently from revert: instead of adding a new commit that undoes the old one, reset moves your current branch to point at a different commit altogether. That makes it fast and clean for commits nobody else has seen yet, and risky for commits they have.

Reset is the broader, easier to misuse cousin of restore and revert above. You will not need it for most day-to-day fixes, restore, revert, and stash cover the vast majority of "help, undo this" moments. You will see it in other people's instructions though, so here is the headline: reset moves your branch backward in history, and one of its modes, --hard, throws away uncommitted work with no confirmation. If a command you are copying from somewhere includes git reset --hard, read what it does before you run it.

git reset <commit> moves your branch to point at that commit, and --soft, --mixed, and --hard decide how much else moves with it. Picture three layers: your commit history, where the branch pointer sits, the staging area, what is lined up to commit, and your working directory, the files on disk. The short hashes below read the same way History covers with git log --oneline, and the target HEAD~1 means "one commit before HEAD", the standard way to say "undo the newest commit".

bash
$ git log --oneline
8f3c7d1 Cache forecast responses in localStorage
4f2a1c9 Fix temperature conversion in Celsius-to-Fahrenheit formula
5f3d8b2 Add five-day forecast to the dashboard
1a2b3c4 Set up project structure

$ git reset --soft HEAD~1

--soft moves the branch pointer back one commit and stops there. The staging area and working directory are untouched, so everything from the undone commit sits staged, ready to be recommitted however you like.

bash
$ git reset --mixed HEAD~1

--mixed is what runs if you leave the flag off. It moves the branch pointer back and also clears the staging area, so the undone commit's changes land back as unstaged edits in your working directory. Nothing is lost, run git add again before committing.

bash
$ git reset --hard HEAD~1

git reset --hard moves the branch pointer back and overwrites your working directory to match, so any uncommitted work and the undone commit both disappear from view in one step, with no prompt asking if you are sure. This is the mode to pause before running.

This also explains what git commit --amend was doing back in the commit loop: amend is close to a reset --soft HEAD~1 followed straight away by a new commit, which is why it replaces your last commit instead of stacking on top of it.

Reset only ever moves pointers and, in --hard mode, the working directory to match. It never deletes a commit object outright. The commit a reset --hard left behind is still sitting in Git's object database, unreachable from any branch, which matters because there is a way to reach it again: git reflog, next up. On a shared branch though, moving your branch backward and pushing means force-pushing over commits your teammates already have, so their next pull disagrees with the reworked history. That is the practical reason reset stays a local cleanup tool while revert covers anything that has already left your machine.

JunoRewinding with git reset You will not reach for git reset in most day-to-day fixes, restore, revert, and stash already cover nearly everything. Still, know the shape of it: reset moves your branch back to an earlier commit, and its --hard mode wipes your working directory to match with no confirmation. Treat any command you copy that includes reset --hard with real caution.
JunoRewinding with git resetgit reset --soft keeps the undone commit's changes staged, --mixed (the default) unstages them but keeps the edits, --hard throws the edits away entirely. commit --amend is basically a soft reset immediately followed by a new commit, which is why it replaces the last commit instead of stacking on top of it.
JunoRewinding with git reset Reset only moves pointers, and with --hard, your working directory to match, it never deletes the commit object itself. That object stays in the database until the reflog and garbage collection catch up with it. Push a rewritten branch and your teammates' next pull collides with history that no longer matches theirs.

Choosing revert or reset

The rule that decides which tool to reach for: has anyone else already pulled this commit? If yes, revert. If the commit only exists on your machine, on a branch you have not pushed, reset is fine and often the tidier fix. When you are not sure, revert is always the safe choice, since it works in both cases.

"Has anyone pulled it" really means "is this commit reachable from a reference someone else has", which in practice means whether it sits on a branch that has been pushed. A commit on a local branch you invented five minutes ago and never pushed is open territory for reset. The moment you push it, treat it as shared: resetting it locally and force-pushing means coordinating with whoever else touches that branch first.

JunoChoosing revert or reset Ask one question before undoing a commit: has anyone else already pulled it? If yes, reach for revert, it is always safe. If the commit only exists on your machine, reset works fine too. When you are not sure which applies, revert is the safer default.
JunoChoosing revert or reset Once a commit is pushed and anyone might have it, reach for revert. While it is still purely local to you, reset is fine and often the tidier fix, since it does not leave a "revert of a revert" entry sitting in the log.
JunoChoosing revert or reset The real test is reachability: is this commit sitting on a reference anyone else has pulled? Local and unpushed, reset is open territory. Pushed and shared, treat resetting it as something to coordinate first, since a later force-push collides with whatever your teammates already have.

Recovering a commit with git reflog

A reset --hard, a messy rebase, or deleting a branch by mistake can look like a commit vanished completely. It usually has not. Git keeps a local log of everywhere your HEAD and branches have pointed, called the reflog, and that log is almost always your way back to a commit that looks lost.

You are unlikely to need this often, but here is the headline: Git rarely throws anything away the moment it looks like it has. If you ever run reset --hard and realize afterward you needed that commit, there is very likely a way to get it back, covered here for whenever you are ready to go deeper.

The short version: git reflog lists recent positions of HEAD, each with a short hash, and you can git reset --hard any of those hashes to get back to that exact state. It reads like a second history: every place HEAD has stood, including commits your branches no longer point at.

A commit with no branch, tag, or HEAD pointing at it anymore is unreachable: adrift in the repository, still saved, only missing a label that leads back to it. The reflog is a chronological list of everywhere HEAD has moved on your machine, this branch switch, that reset, that rebase, each entry keyed by a short reference like HEAD@{2}. Find the entry from right before the mistake and point a branch back at it:

bash
$ git reflog
2b6f0d1 HEAD@{0}: reset: moving to HEAD~1
8f3c7d1 HEAD@{1}: commit: Cache forecast responses in localStorage
4f2a1c9 HEAD@{2}: commit: Fix temperature conversion in Celsius-to-Fahrenheit formula

$ git reset --hard 8f3c7d1

That is the commit reset --hard seemed to erase, restored. Two limits worth knowing. First, the reflog lives only on your own machine, pushing and pulling never touch it, so it cannot recover a commit a teammate lost on theirs. Second, it does not last forever: Git eventually runs garbage collection and clears out unreachable commits once they have aged past a default cutoff of a few weeks, so the safety net covers recent mistakes rather than the whole life of the project.

JunoRecovering a lost commit If you ever run reset --hard and realize too late you needed that commit, it is probably still recoverable. Git keeps a local record called the reflog of everywhere your work has pointed, and that is usually enough to get a commit back that looked gone. This is deep-dive territory, but it is reassuring to know it is there.
JunoRecovering a lost commitgit reflog lists recent positions of HEAD with short hashes you can reset --hard back to, so a reset that went too far is usually recoverable. It lives only on your own machine though, so it cannot rescue a commit a teammate lost on theirs.
JunoRecovering a lost commit The reflog is a local, chronological record of every place HEAD has stood, which is how a commit reset --hard seemed to erase turns up again by pointing a branch at the right HEAD@{n} entry. It ages out with garbage collection eventually, and it covers your own recent mistakes only. A teammate who lost a commit needs to run the same recovery on their own machine.