Skip to content
Published on

Undoing things in git — picking restore, reset, revert and reflog by situation

Share
Authors

Introduction — one question to ask before you undo anything

Memorizing undo commands means searching for them again every time. reset and revert and restore and checkout all look alike, and the top of the search results is a list of commands that never says which situation it is for. Picking one of them and pasting it in is the most common route to losing a day of work.

Settle just two things before choosing a command and the options narrow to one. First, where is the thing you want to undo. In the working tree, in the staging area (index), or in a commit. Second, has that commit already gone to other people. The first question decides which family of command you need, the second decides whether you are allowed to rewrite history.

Three trees — what can be undone and what cannot

Git holds the state of your files in three places: the snapshot the last commit points at, the index that holds what goes into the next commit, and the working tree currently sitting on disk. Every undo command is distinguished by which of these three it touches.

$ git status --short
M  pricing.ts     # differs from the commit in both the index and the working tree
 M coupon.ts      # differs from the commit only in the working tree
?? refund.ts      # not anywhere

From here comes the one rule that runs through this entire post. Anything that has ever been committed can almost always be recovered, and anything that has never been committed almost never can. The reflog we will look at later is a record of what pointed at commits, not a backup of the working tree. So risk should be graded not by the name of the command but by whether that command overwrites uncommitted changes.

Before you commit — the traps in restore and stash

The most searched-for commands live here. So does the oldest surviving advice.

# Unstage only (file contents stay as they are)
$ git restore --staged pricing.ts

# Throw away the working tree edit (cannot be undone)
$ git restore pricing.ts

# Pull out just the file as of a particular commit
$ git restore --source=HEAD~2 --staged --worktree pricing.ts

The top of the search results still shows plenty of the form below.

$ git checkout -- pricing.ts
$ git reset HEAD pricing.ts

The behavior is the same but it is not recommended. git checkout does two completely different jobs, switching branches and restoring files, under one name, and it behaves against your intent when a file has the same name as a branch. That is exactly why Git 2.23 split the two into git switch and git restore. There is no reason to follow advice from the era when there was only one command.

Untracked files are not restore targets. Removing them is a separate command, and here neither the reflog nor fsck will help you. Always check first.

$ git clean -nd
Would remove refund.ts
Would remove tmp/

$ git clean -fd
Removing refund.ts
Removing tmp/

The stash you reach for when you want to set something aside has three traps.

$ git status --short
 M pricing.ts
?? coupon.ts

$ git stash push -m "쿠폰 작업 중"
Saved working directory and index state On main: 쿠폰 작업 중

$ git status --short
?? coupon.ts

First, untracked files stay right where they are. Sweeping newly created files aside needs a separate option, and including files caught by the ignore list needs yet another one.

$ git stash push -u -m "쿠폰 작업 중"    # include untracked files
$ git stash push -a -m "빌드 산출물까지"  # include ignored files too

Second, if applying it conflicts, the stash does not disappear and stays in the list. It is deleted only on success, so if you do not delete it after resolving the conflict you end up with two copies of the same change.

$ git stash list
stash@{0}: On main: 쿠폰 작업 중
$ git stash drop

Third, and this is the trap that bites most often in practice, a stash is not tied to a branch. Pop it on a different branch and it applies right there. It is a convenient feature, but pop it in the wrong place and someone else changes land on the wrong branch.

Committed but not pushed — amend and reset

This splits on whether you only want to fix the message or want the commit gone entirely.

# Fix only the message
$ git commit --amend -m "쿠폰 정책 반영 — 만료 검사 추가"

# Add a file you forgot to the commit you just made (message unchanged)
$ git add src/order/coupon.ts
$ git commit --amend --no-edit

--amend also creates a new commit object. The hash changes, so the same rules as rebase apply to commits you have already pushed.

reset is what you use when you want the commit itself taken back. Its three options differ in how far across the three trees they carry the move.

$ git log --oneline
08daaa8 쿠폰 정책 반영
dd596de 할인율 적용
fc945f2 가격 계산기 초안

$ git reset --soft HEAD~2
$ git status --short
M  pricing.ts

$ git reset --mixed
Unstaged changes after reset:
M	pricing.ts
$ git status --short
 M pricing.ts

--soft removed two commits while leaving their content staged, and --mixed unstaged it as well. Neither one lost any file content.

CommandBranch refIndexWorking treeWhat you can loseWhen to use it
git reset --softMovesUntouchedUntouchedNothingRebundling several commits into one
git reset (mixed, the default)MovesSet to the target commitUntouchedOnly the staged stateUndoing commits and staging while keeping files
git reset --hardMovesSet to the target commitSet to the target commitEvery uncommitted changeOnly when you are sure you are discarding
git reset --keepMovesSet to the target commitAborts on overlapNothingRewinding while protecting local edits
git reset --mergeMovesSet to the target commitCleans up merge stateStaged changesBacking out a failed merge

The two most practically important rows in the table are the last two. When people want to rewind but do not want to lose work in progress, they reflexively type --hard, and yet there is an option that fits that exact situation. --keep stops and does nothing when the range being rewound overlaps your local edits. Being refused before the overwrite beats regretting it afterwards.

Already pushed — why revert is the only safe answer

From here the rules change. If the commit is already in someone else repository, every way of removing it breaks that person history. Instead of fixing history, the answer is to append to it.

$ git revert 08daaa8
[main 5b21c4e] Revert "쿠폰 정책 반영"
 1 file changed, 1 deletion(-)

revert stacks one more new commit that applies the change in reverse. No commit disappears, so everyone else just fetches as usual. If you want to undo several at once but produce only one commit, do this.

$ git revert --no-commit dd596de^..08daaa8
$ git commit -m "쿠폰 정책 롤백"

A merge commit has two parents, so you have to say which side to undo relative to. Usually that is the target branch, the first parent.

$ git revert -m 1 40600e3
[main 3c06ce3] Revert "Merge branch 'feature/checkout'"
 1 file changed, 2 deletions(-)
 delete mode 100644 retry.txt

There is a little-known trap here. If you revert a merge and then fix the branch and merge it again, the changes you reverted do not come back. As far as Git is concerned that branch is already merged, because merging is judged purely by reachability in history. The actual files stay exactly as the revert commit left them. The right answer is to revert the revert commit once before merging again.

$ git revert 3c06ce3     # revert the revert
$ git merge feature/checkout

It is a common enough accident that the Git documentation has a separate note about this situation. When you undo a merge, decide first whether you will put this branch back in later, and if you will, it is cleaner to revert only the offending commit on the branch instead of the merge.

The advice you should never follow is equally clear: rewinding a commit off a shared branch and then force-pushing it away.

# Do not do this on a shared branch
$ git reset --hard HEAD~1
$ git push --force origin main

The clones of everyone who already fetched are untouched, and the moment they push, the commit you deleted comes back to life. If what you were trying to delete was a secret, it is worse than that. A force push does not make the object vanish from the server immediately, and by then CI logs and forks already hold a copy. The correct order for that case is written up separately in the final section of When .gitignore is not working.

reflog — what you deleted is usually still there

This is the first place to look when you deleted a branch or blew a commit away with --hard. Git keeps a local record of which commits HEAD and each branch pointed at.

$ git reflog
fc945f2 HEAD@{0}: reset: moving to HEAD~2
08daaa8 HEAD@{1}: commit: 쿠폰 정책 반영
dd596de HEAD@{2}: commit: 할인율 적용
fc945f2 HEAD@{3}: commit (initial): 가격 계산기 초안

$ git reset --hard HEAD@{1}
HEAD is now at 08daaa8 쿠폰 정책 반영

For a deleted branch there is one thing you have to know. Deleting a branch deletes that branch reflog along with it. So looking it up by branch name returns nothing. The reflog of HEAD, however, still holds the commits you made on that branch.

$ git branch -D tmp/lost
Deleted branch tmp/lost (was 990a949).

$ git reflog
08daaa8 HEAD@{0}: checkout: moving from tmp/lost to main
990a949 HEAD@{1}: commit: 환불 규칙 추가
08daaa8 HEAD@{2}: checkout: moving from main to tmp/lost

$ git branch tmp/lost 990a949

It is also worth remembering that the deletion message itself contains the commit hash. Scrolling up a little in the terminal often finishes the recovery.

You should also know the limits of the reflog precisely. It is a local file, it does not travel with a clone, and it is not forever. The defaults are 90 days for reachable entries and 30 days for unreachable ones.

$ git config gc.reflogExpire
90 days
$ git config gc.reflogExpireUnreachable
30 days

And the two commands below remove that safety net instantly. They show up in search results a lot because they shrink the repository, but you should only run them knowing that recoverability disappears the moment you do.

$ git reflog expire --expire=now --all
$ git gc --prune=now

When even the reflog has nothing — fishing objects out with fsck

Sometimes a rebase went sideways, or a stash was dropped by mistake, or a commit came loose in a way that leaves no reflog entry. Then you scan the object database directly.

$ git fsck --full --unreachable --no-reflogs
Checking object directories: 100% (256/256), done.
unreachable blob ed7ce12274bc71c66cb596feaaf9f02ce91b820e
unreachable tree 970df65883de43d243ce919be12841c5b9f01857
unreachable commit 990a9490f7069940e9d425bde9d1f5bc44048b1d

$ git show --stat 990a949
commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
Author: Demo <d@e.com>

    환불 규칙 추가

 pricing.ts | 1 +

Once you find it, give it a name and bring it back.

$ git branch recovered/refund 990a949

There is one more fact people often miss here. A blob shows up in that output because content that was only staged and never committed is already stored as an object. If you ran git add even once, the file content as of that moment is inside the repository. Even after you wiped it with --hard you can pull it out like this.

$ git cat-file -p ed7ce12 > pricing.recovered.ts

If you want everything at once as files, there is a dedicated option. It unpacks commits and other objects into separate directories.

$ git fsck --lost-found
$ ls .git/lost-found/commit .git/lost-found/other

This route has a deadline too. Housekeeping deletes unreachable objects older than two weeks by default, so an old accident may be beyond saving even here.

$ git config gc.pruneExpire
2.weeks.ago

Closing — remember the boundaries, not the commands

You do not need to memorize every undo command. When something goes wrong there are only two questions to ask yourself. Has this change ever been committed, and has this commit gone to other people.

If it has been committed, the reflog and fsck almost always fish it back out. If it has never been committed, no command will help you. And if it has already gone to other people, appending a revert instead of fixing history is the only safe path. Skill at undoing is not knowing the commands, it is the habit of making one commit before you type a dangerous one.

If you are curious why the commands split up the way they do, Reading Git commands through internals covers what refs and the index really are.