Skip to main content
CodeOath
← All posts

Git95 min total · 17 parts

Git Internals and Workflows: Branching, Merging, and Rebasing

Part 11 of 17 · ~5 min

git reset vs. git revert vs. git checkout/git restore

Three weeks later, shift swapping has shipped. And it is causing an incident: employees are swapping into shifts that push them past their weekly hour cap, payroll is flagging overtime nobody approved, and it needs to stop today.

The feature is one merge commit on main, f2c40a9, and it has been pushed, pulled, deployed, and tagged. Everybody has it.

Your instinct might be to move main back to before the merge. Resist it, and understand why, because this is the decision that separates the four undo commands.

CommandWhat it doesRewrites history?Safe on pushed/shared commits?
git reset --soft <commit>Moves the branch pointer back; all later changes stay stagedYesNo
git reset --mixed <commit> (default)Moves the branch pointer back; changes stay in the working tree, unstagedYesNo
git reset --hard <commit>Moves the branch pointer back and discards later commits and uncommitted changesYesNo
git revert <commit>Creates a new commit that undoes an earlier one's changesNo — it adds to historyYes
git restore <file>Discards uncommitted changes to a file, back to the last commit (or --source=<commit>)No — does not touch historyYes
git restore --staged <file>Unstages a file, leaving the working-tree change aloneNoYes
git checkout <commit> -- <file>Restores one file's contents from a specific commit, moving no branch pointerNoYes

The three reset modes differ only in what happens to the changes from the commits you moved past. --soft keeps them staged, which is what makes it the quick way to combine your last three commits into one: move the pointer back three, then commit everything that is now staged. --mixed keeps them as working-tree edits. --hard throws them away.

All three move a branch label backwards, which is the problem here. If you git reset --hard a pushed main, your local main now has less history than origin/main, and nothing has actually been undone for anyone else. The next git pull brings the merge straight back. The only way to make it stick is to force-push over a shared trunk, which is how a team loses a morning.

So: if the bad commit is public, revert it.

git revert f2c40a9
error: commit f2c40a9b83e17d6045c2ae9f13b80d7e5a6c4218 is a merge but no -m option was given.
fatal: revert failed

A reasonable failure, and a good illustration of how merge commits differ. f2c40a9 has two parents. "Undo this commit" is ambiguous — undo it relative to main's history, or relative to the feature branch's? You have to say which parent is the mainline you want to keep:

git revert -m 1 f2c40a9

-m 1 means "the first parent is the one to keep," and for a merge commit created on main, the first parent is always main's previous tip. So this produces a new commit that removes everything the feature branch contributed and leaves main otherwise intact:

[main 0d3e8b5] Revert "Merge branch 'feature/shift-swap'"

Nothing was rewritten. f2c40a9 is still there, still in everyone's history, still the honest record that the feature landed. On top of it sits an equally honest record that it was taken back out. Everyone downstream gets both with an ordinary git pull, and nobody's local repository disagrees with anybody else's about what happened.

The trap that follows a reverted merge

This is the part that catches people, so it is worth doing before it happens to you.

Two weeks later, the hour-cap bug is fixed and the feature should go back in. So you merge the branch again:

git switch main
git merge --no-ff feature/shift-swap
Already up to date.

And swap.js is not there. The merge did nothing, the files are missing, and Git is insisting everything is fine.

Git is right, on its own terms. Merging compares both sides against their shared ancestor, and every commit on feature/shift-swap is already an ancestor of main — they were merged weeks ago and that merge commit never went anywhere. What removed the code was a later, separate commit, 0d3e8b5. From the merge's point of view there is nothing left to bring in, because it already brought it in once.

The fix is to undo the undo:

git revert 0d3e8b5
[main e91c5d0] Reapply "Merge branch 'feature/shift-swap'"

Git even names it for you. The files come back, the history says plainly that the feature landed, was pulled, and was restored, and nobody has to reconstruct that from a Slack thread.

If the feature branch has picked up new commits since the revert, you need both moves: revert the revert to restore the original merge's contribution, then merge again to bring in what is new.

The file-level commands

git restore and git checkout -- <file> do not belong in the same mental category as the other two, and it is worth separating them properly. Neither touches commit history at all. They restore file contents.

git restore src/roster/swap.js                       # discard my uncommitted edits to this file
git restore --staged src/roster/swap.js              # unstage it, keep the edits
git restore --source=v2.2.0 src/payroll/export.js    # bring back this file as of the v2.2.0 tag

git restore and git switch exist because git checkout used to mean both "switch branches" and "throw away my changes to this file," which is an alarming amount of range for one command. The split is not cosmetic: git restore can only ever mean file-level undo, so there is no version of it that moves you somewhere unexpected. Prefer it in new habits, and expect to keep reading git checkout in older scripts and answers forever.

One warning that applies to restore, checkout -- <file>, and reset --hard equally: uncommitted work these commands discard is genuinely gone. The reflog chapter at the end can rescue a startling amount of lost work, but only work that became a commit at some point. An edit that was never committed was never an object in the database, and nothing can bring it back.