Ran Wei/Git Series
中文
Git and Version Control — Ran Wei

Rewriting history and recovering

Put work aside with stash, take back commits with reset, tidy a branch with rebase and squash, follow the one rule about shared history, and rescue almost anything with the reflog.

3 hours3 study sessions5 command sessionsGit 2.50.1 (Apple Git-155)

By the end you can

  • State the rule about rewriting shared history, and explain why it matters.
  • Put unfinished work aside and bring it back with git stash.
  • Take back commits with git reset --soft, --mixed and --hard, knowing what each keeps.
  • Rebase a branch onto main, squash a fix into an earlier commit, and push a rewritten branch safely with --force-with-lease.
  • Find lost commits in the reflog and restore them.

Before you start

Modules 3 to 7, especially amend and revert (Module 3) and pushing branches (Module 6).

Contents

Study plan

3 hours

Times are estimates and include typing the commands. Progress is saved in this browser and shared by both language editions.

Session 160 min
Rewinding safely
Session 260 min
Tidying a branch
Session 360 min
Recovery and practice
1

The one rule

This module gives you tools that change history: commands that move a branch backwards, replay commits somewhere else, or fold several commits into one. Used on your own work, they help you keep a clean, readable history. Used carelessly on shared work, they cause real trouble. So first, the one rule:

Key idea

Do not rewrite commits that other people already have. Once you have pushed commits to a branch that others use, such as main, treat them as fixed. Undo them with git revert (Module 3), which adds a new commit, rather than by rewriting them.

Why? Rewriting history never edits commits; it makes new ones, with new hashes, and moves the branch to them, as you saw with --amend in Module 3. Everyone who already has the old commits now has a history that no longer matches yours. Their next pull merges the old and new versions together, duplicating work. Your next push is rejected, and forcing it throws their work away.

On your own branches, before anyone else builds on them, rewriting is fine, and often a kindness to reviewers. A feature branch that only you work on, even one already pushed for a pull request, counts as yours, as long as you tell Git that you mean to overwrite it (Section 4).

Two tools in this module rewrite nothing: git stash (Section 2) puts uncommitted work aside, and git reflog (Section 6) finds commits you thought you had lost.

2

Putting work aside

You are half-way through a change when something more urgent comes up: a fix on another branch, or a colleague’s pull request to try. Your work is not ready to commit, and switching branches with uncommitted changes is risky (Module 4, Exercise 4). git stash solves this. It saves your uncommitted changes, both staged and unstaged, on a stack called the stash, and returns your working tree to the last commit. Later, git stash pop puts the changes back and removes them from the stash.

In the session, Alex is adding an ingredient to the pancakes when the README needs fixing first. To follow along, start an edit to any tracked file, then stash it.

Try itPut unfinished work aside
Create or edit pancakes.md so that it contains:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
- a pinch of
git stash
Output
Saved working directory and index state WIP on main: 8bf3c2d Add a pancake recipe
git status
Output
On branch main
nothing to commit, working tree clean
git stash list
Output
stash@{0}: WIP on main: 8bf3c2d Add a pancake recipe
Create or edit README.md so that it contains:
# Family recipes

Recipes we cook again and again, with notes on what worked.
git commit -am "Say what the notes are for"
Output
[main a863ac0] Say what the notes are for
 1 file changed, 1 insertion(+), 1 deletion(-)
git stash pop
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:   pancakes.md

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (0c8aa5df3461004051d9b57493e58b6ac5cc745f)
git diff
Output
diff --git a/pancakes.md b/pancakes.md
index 871d830..d511981 100644
--- a/pancakes.md
+++ b/pancakes.md
@@ -3,3 +3,4 @@
 - 200 g flour
 - 2 eggs
 - 300 ml milk
+- a pinch of
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

git stash reports what it saved, WIP on main (“work in progress”), and names the commit it was based on. git status then shows a clean working tree, and git stash list shows one entry, stash@{0}, the newest. With a clean tree, Alex commits the README change. git stash pop brings the half-written pancake line back as an unstaged change, and git diff shows it waiting to be finished.

A few more things to know:

  • git stash saves only tracked files. To include new, untracked files, use git stash -u.
  • git stash list shows every entry, newest first. git stash apply puts the newest back but keeps it on the stash; git stash drop deletes it.
  • If the stashed changes conflict with what you have committed since, git stash pop stops with conflict markers. Resolve them as in Module 5, then run git stash drop, because in that case the entry is kept.
  • Stashes are local: they are not pushed. Do not leave work there for long; a branch with a work-in-progress commit is easier to find again.
3

Taking back commits

git reset <commit> moves your current branch back to an earlier commit, taking back the commits after it. Unlike git revert, it rewrites history, so use it only on commits that you have not pushed. Its options say what happens to the changes those commits contained:

  • git reset --soft <commit> moves only the branch. The changes stay in the staging area, ready to commit again.
  • git reset <commit>, the default, called mixed, also resets the staging area. The changes stay in your working tree, unstaged.
  • git reset --hard <commit> also resets the working tree. The changes are gone from your files, and so is any uncommitted work.
What git reset keeps The branch Staging area Working tree --soft moves back kept kept --mixed (default) moves back reset kept --hard moves back reset reset: changes lost Commits that are no longer on any branch can still be found in the reflog for a while
Figure 8.1

What each form of git reset keeps. All three move the branch back; they differ in what happens to the staging area and the working tree.

As in Module 3, HEAD~1 means “one commit before HEAD”. In the session, Alex commits a pinch of salt with a vague message, takes the commit back with --soft and commits again with a better message, then takes it back once more with the default reset. To follow along, add a line to any recipe and commit it with git commit -am.

Try itTake back the last commit
Create or edit pancakes.md so that it contains:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
- a pinch of salt
git commit -am "Add salt"
Output
[main 631ac3e] Add salt
 1 file changed, 1 insertion(+)
git reset --soft HEAD~1
git status
Output
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   pancakes.md
git commit -m "Add a pinch of salt to the pancakes"
Output
[main bdac642] Add a pinch of salt to the pancakes
 1 file changed, 1 insertion(+)
git reset HEAD~1
Output
Unstaged changes after reset:
M	pancakes.md
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:   pancakes.md

no changes added to commit (use "git add" and/or "git commit -a")
git log --oneline
Output
8bf3c2d Add a pancake recipe
907a979 Add a README
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

git reset --soft HEAD~1 prints nothing, but git status shows the salt as Changes to be committed: the commit is gone, and its change is staged again. That makes --soft a way to redo a commit, for example to merge the last few commits into one: reset back over them with --soft, then commit once.

After the default reset, Git reports Unstaged changes after reset: and lists pancakes.md. git status agrees: the change is now unstaged, in the working tree. This is the way to split a commit into smaller ones: reset it, then stage and commit the parts separately. git log --oneline confirms that both salt commits are gone from main.

Pitfall

git reset --hard throws away uncommitted changes in your working tree, and Git cannot bring those back. Commits it takes back can usually be recovered from the reflog (Section 6), but uncommitted work cannot. Run git status first, and stash anything you want to keep.

4

Rebasing

In Module 7, Alex brought a feature branch up to date by merging origin/main into it. Rebasing is the other way. git rebase main takes the commits on your branch that main does not have, and replays them, one by one, on top of the newest commit of main. The result is the same files as a merge, but a straight line of history with no merge commit, as if you had started the branch from today’s main.

Replaying makes new commits, with new hashes, so rebasing rewrites the branch. That is fine for your own feature branch, but it has a consequence when the branch is already pushed, and the session shows it. Alex’s soups branch is on the server, and main has moved on. To follow along, make a commit on a branch and push it, then add a commit to main.

Try itReplay a branch on top of main
* 64a7f92 (origin/main, main) Say what the notes are for
| * 968c34b (HEAD -> soups, origin/soups) Add a tomato soup recipe
|/
* 8bf3c2d Add a pancake recipe
* 907a979 Add a README
git rebase main
Output
Successfully rebased and updated refs/heads/soups.
* a11c867 (HEAD -> soups) Add a tomato soup recipe
* 64a7f92 (origin/main, main) Say what the notes are for
| * 968c34b (origin/soups) Add a tomato soup recipe
|/
* 8bf3c2d Add a pancake recipe
* 907a979 Add a README
git push
Output
To /srv/git/recipes.git
 ! [rejected]        soups -> soups (non-fast-forward)
error: failed to push some refs to '/srv/git/recipes.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
git push --force-with-lease
Output
To /srv/git/recipes.git
 + 968c34b...a11c867 soups -> soups (forced update)
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

Compare the graphs. Before, soups and main have split, as in a three-way merge. After git rebase main, which reports Successfully rebased and updated refs/heads/soups, the soup commit sits on top of main’s newest commit as a new commit, a11c867. The old soup commit, 968c34b, is still where origin/soups points: the server has not changed.

So git push is rejected as non-fast-forward: the server’s soups contains 968c34b, which your rebased branch has replaced. Here you know the branch is yours, so you overwrite it with git push --force-with-lease. Git reports (forced update). --force-with-lease refuses if the server’s branch is not where your origin/soups says it is, that is, if someone pushed to it since you last fetched. Plain --force overwrites regardless, which can destroy a colleague’s work; avoid it.

When should you rebase rather than merge? Many teams rebase their own feature branches to keep history linear, and merge when the branch is finished. Others always merge. Either way, the one rule applies: never rebase a branch that others are working on. Remember too that git pull can rebase instead of merge, with git pull --rebase or the pull.rebase true setting from Module 6.

If a replayed commit conflicts, the rebase stops, much like a merge. Resolve the file, git add it, then run git rebase --continue; or git rebase --abort to go back to where you started. Exercise 5 shows one, and a difference from merging that surprises everyone the first time.

5

Squashing a fix

Before a branch is reviewed, you may notice a mistake in one of its earlier commits: a typo, a forgotten line. A separate “fix typo” commit makes the history noisier for everyone who reads it later. An interactive rebase, git rebase -i, lets you edit the list of commits before they are replayed: reorder them, reword their messages, drop them, or squash several into one.

The easiest way to squash a fix is to let Git write the instructions for you:

  1. Make the fix, and commit it with git commit --fixup <commit>, naming the commit it belongs to. Git gives it the message fixup! followed by that commit’s message.
  2. Run git rebase -i --autosquash main. Git opens your editor with a to-do list, one line per commit on the branch, already arranged so that the fixup follows the commit it fixes, marked fixup.
  3. Save and close the editor. Git replays the commits, folding the fix into the earlier commit.

In the session, the soup recipe’s title has a typo, “Tomatoe”, and a bread recipe has been committed since. To follow along, make two commits on a branch, then fix something from the first.

Try itFold a fix into an earlier commit
git log --oneline
Output
49f29eb Add a soda bread recipe
bc5616c Add a tomato soup recipe
8bf3c2d Add a pancake recipe
907a979 Add a README
Create or edit soup.md so that it contains:
# Tomato soup

- 1 kg tomatoes
git add soup.md
git commit --fixup HEAD~1
Output
[soups 2b52993] fixup! Add a tomato soup recipe
 1 file changed, 1 insertion(+), 1 deletion(-)
git log --oneline
Output
2b52993 fixup! Add a tomato soup recipe
49f29eb Add a soda bread recipe
bc5616c Add a tomato soup recipe
8bf3c2d Add a pancake recipe
907a979 Add a README
git rebase -i --autosquash main
Output
Successfully rebased and updated refs/heads/soups.
git log --oneline
Output
4af79ad Add a soda bread recipe
86756a5 Add a tomato soup recipe
8bf3c2d Add a pancake recipe
907a979 Add a README
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

git commit --fixup HEAD~1 names the commit before the newest, the soup commit, and Git calls the new commit fixup! Add a tomato soup recipe. When you run git rebase -i --autosquash main on your computer, the editor shows this to-do list (followed by a long comment explaining the commands):

pick bc5616c # Add a tomato soup recipe
fixup 2b52993 # fixup! Add a tomato soup recipe
pick 49f29eb # Add a soda bread recipe

# Rebase 8bf3c2d..2b52993 onto 8bf3c2d (3 commands)

Each line is a command and a commit, applied from top to bottom: pick replays a commit as it is, and fixup folds it into the commit above, keeping that commit’s message. Saving the list unchanged runs it, as the session does. The final git log --oneline shows two commits instead of three, with new hashes: the soup commit now contains the corrected title.

You can also edit the list yourself. Change pick to reword to change a message, to squash to fold a commit into the one above and combine both messages, or to drop to remove it; move lines to reorder commits. If you save an empty list, the rebase stops without changing anything.

6

The reflog

Commits that no branch points at any more are not deleted at once. Git keeps them for a while, and the reflog, short for reference log, records every position HEAD has had: each commit, reset, switch, merge and rebase. git reflog lists them, newest first, so you can find a commit that a hard reset, a rebase or a deleted branch took away.

In the session, Alex resets two commits away by mistake, then gets them back. To follow along, make two commits, then git reset --hard HEAD~2.

Try itRecover commits after a hard reset
git log --oneline
Output
ab03771 Add a tomato soup recipe
98b66a3 Add a lemon cake recipe
8bf3c2d Add a pancake recipe
907a979 Add a README
git reset --hard HEAD~2
Output
HEAD is now at 8bf3c2d Add a pancake recipe
git log --oneline
Output
8bf3c2d Add a pancake recipe
907a979 Add a README
git reflog
Output
8bf3c2d HEAD@{0}: reset: moving to HEAD~2
ab03771 HEAD@{1}: commit: Add a tomato soup recipe
98b66a3 HEAD@{2}: commit: Add a lemon cake recipe
8bf3c2d HEAD@{3}: commit: Add a pancake recipe
907a979 HEAD@{4}: commit (initial): Add a README
git reset --hard ab03771
Output
HEAD is now at ab03771 Add a tomato soup recipe
git log --oneline
Output
ab03771 Add a tomato soup recipe
98b66a3 Add a lemon cake recipe
8bf3c2d Add a pancake recipe
907a979 Add a README
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

After git reset --hard HEAD~2, git log --oneline no longer shows the cake and soup commits. git reflog still does. Each line shows where HEAD was, a name for that position, and what moved it: HEAD@{0} is now (reset: moving to HEAD~2), and HEAD@{1} is just before, after the soup commit ab03771. git reset --hard ab03771 moves main back there, and the log is exactly as it was.

To rescue a commit without moving your branch, create a new branch at it instead, for example git branch rescue ab03771. That also brings back a branch deleted with git branch -D: find its last commit in the reflog, and create the branch again.

Note — HEAD@{1} and PowerShell

You can also name positions in the reflog directly, as in git reset --hard HEAD@{1}. In PowerShell, put such names in quotes, 'HEAD@{1}', because PowerShell gives braces a meaning of its own. The hash from the reflog works in every shell.

The reflog has limits: it is local to your repository and never pushed, and Git eventually deletes old entries, by default after 90 days, or 30 days for commits no longer on any branch. And it records commits only, so it cannot help with uncommitted changes lost to git reset --hard or git restore. Commit often, and very little can be lost for good.

7

Exercises

Exercise 1★★★conceptual4 min

Safe to rewrite? Which of these are safe? Explain each.

  1. Amending your last commit, which you have not pushed.
  2. Rebasing your feature branch, pushed for a pull request that only you work on, and pushing it with --force-with-lease.
  3. Resetting main back two commits, after pushing those commits to the shared repository.
  4. Reverting a commit on main that the whole team has.
Show solution
  1. Safe: nobody else has the commit.
  2. Safe: the branch is yours, and --force-with-lease stops if someone has pushed to it after all. Tell reviewers you have rewritten it.
  3. Not safe: the team already has those commits. Use git revert instead.
  4. Safe: reverting adds a new commit and rewrites nothing.
Exercise 2★★★coding5 min

An urgent fix. You are half-way through a soup recipe on the soups branch when the README on main needs a fix. Put your work aside, make the fix on main, and return to your soup with your work restored. To follow along, start an edit on a branch without committing it.

Show solution

Stash, switch, commit the fix, switch back and pop:

SolutionStash, fix something else, and carry on
git stash
Output
Saved working directory and index state WIP on soups: 35277d9 Start a tomato soup recipe
git switch main
Output
Switched to branch 'main'
Create or edit README.md so that it contains:
# Family recipes

Recipes we cook again and again, with notes on what worked.
git commit -am "Say what the notes are for"
Output
[main 56ba014] Say what the notes are for
 1 file changed, 1 insertion(+), 1 deletion(-)
git switch soups
Output
Switched to branch 'soups'
git stash pop
Output
On branch soups
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:   soup.md

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (22d318f23c71ae4df59525bf1de57927cd36725e)
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

The stash entry records the branch it came from (WIP on soups), but you can pop it on any branch.

Exercise 3★★★conceptual5 min

Which reset? None of these commits has been pushed. Which form of git reset HEAD~1 fits each situation?

  1. Your last commit mixes two unrelated changes, and you want to commit them separately.
  2. You want to add one more file to your last commit and write a better message.
  3. Your last commit was a failed experiment, and you want neither the commit nor its changes.
Show solution
  1. The default (mixed): the changes come back unstaged, so you can stage and commit each part separately.
  2. --soft: the changes stay staged; add the file and commit again. (git commit --amend does the same in one step.)
  3. --hard: the commit and its changes are gone. Check git status first, so that you do not lose uncommitted work.
Exercise 4★★★conceptual5 min

A deleted branch. Yesterday you deleted a branch called experiment with git branch -D, and today you need one of its commits. How do you get the branch back?

Show solution

Run git reflog and find checkout: moving from experiment to main. The hash on that line is where HEAD went, the tip of main; the line just below it shows where HEAD was before, the last commit of experiment. Note that hash, then run git branch experiment <hash>. The branch is back, with all its commits.

Exercise 5★★★coding7 min

A conflict during a rebase. On less-sugar, you changed the sugar to 30 g; meanwhile main changed it to 40 g. Rebase less-sugar onto main, settling on 35 g. To follow along, make the two changes as in Module 5, Section 3, but on a new branch such as less-sugar-2, because less-sugar already exists in your repository.

Show solution

The rebase stops at the conflicting commit. Resolve the file, stage it, and continue:

SolutionResolve a conflict during a rebase
git rebase main
Output
Auto-merging pancakes.md
CONFLICT (content): Merge conflict in pancakes.md
error: could not apply cbca0db... Use less sugar
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply cbca0db... # Use less sugar
cat pancakes.md
Output
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
<<<<<<< HEAD
- 40 g sugar
=======
- 30 g sugar
>>>>>>> cbca0db (Use less sugar)
Create or edit pancakes.md so that it contains:
# Pancakes

- 200 g flour
- 2 eggs
- 300 ml milk
- 35 g sugar
git add pancakes.md
git rebase --continue
Output
[detached HEAD 012cc14] Use less sugar
 1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/less-sugar.
git log --oneline
Output
012cc14 Use less sugar
5728578 Reduce the sugar a little
17d419f Add a pancake recipe
Output from git version 2.50.1 (Apple Git-155), run while this page was built. Other versions of Git may word some messages differently.

Look at the markers: in a rebase, <<<<<<< HEAD holds main’s version, 40 g, and >>>>>>> cbca0db (Use less sugar) holds yours. The labels are the other way round from a merge, because a rebase replays your commits on top of main, so HEAD is the new base. git rebase --continue then commits the replayed commit, letting you edit its message on your computer, and finishes the rebase.

Exercise 6★★★conceptual4 min

Which tool? Name the command for each situation.

  1. The message of your last commit, not yet pushed, has a typo.
  2. A commit on the shared main broke the build.
  3. You need a clean working tree for ten minutes, without committing.
  4. Your pull request has a “fix typo” commit that belongs in an earlier commit.
  5. You reset too far and need the commits back.
Show solution
  1. git commit --amend.
  2. git revert <commit>.
  3. git stash, then git stash pop.
  4. git commit --fixup <commit> and git rebase -i --autosquash main, then git push --force-with-lease.
  5. git reflog, then git reset --hard <hash>.
8

Self-check quiz

1
What is the rule about rewriting history?
2
What does git stash do?
3
After git reset --soft HEAD~1, where are the last commit’s changes?
4
Which command can destroy uncommitted work for good?
5
After rebasing a branch you had already pushed, why is git push rejected?
6
During a rebase onto main, which version lies between <<<<<<< HEAD and =======?
7
What does git reflog show?
9

Further reading