Rebase and undo
Trace commits and branches, choose an appropriate undo operation, and resolve a small merge conflict.
THE IDEA
Rebase: replay your commits somewhere else
git rebase main takes the commits that exist only on your branch and re-creates them, one at a time, on top of main. The result is a straight line. The copies are new commits: they have a different parent, so they get different ids. The originals still exist but nothing names them.
Before (on feature)
git merge main
git rebase main
Merge keeps F1 and F2 and adds one commit that joins the lines. Rebase builds new copies of F1 and F2 on top of M2: look at their ids, they changed. The dashed originals are what feature used to be.
Undoing: move a pointer, or add a commit
git reset moves the current branch label to another commit. Its three modes differ in how far the change reaches: --soft moves only the label, --mixed (the default) also resets the staging area, --hard also overwrites your files. git revert is different: it leaves history alone and adds a commit that does the opposite.
Start: last commit C3, plus one staged edit and one newer unstaged edit of a.txt
a5 (unstaged)
a4 (staged)
a3
b2
b2
b2
new or untracked differs from the area to its right in sync
Predict first. A bad commit is already pushed to a shared branch that colleagues pulled. Which undo is safer?
Chapter context
Git records snapshots in a graph of commits. Branch names are movable references to commits. Treat the working tree, staging area and recorded history as distinct states, and many apparently mysterious commands become predictable.
TRY IT YOURSELF
You start on feature, which branched before main got M2. The graph includes dashed commits that no branch names any more.
- You are on feature, which forked before main moved. Replay it on top of main with rebase (linear history, new commit ids)
- Move main up to feature. After a rebase this is a pure fast-forward
- Commit a file debug.txt as Add debug output, then undo that commit safely by adding an inverse commit (history keeps both)
- Commit wip.txt as Add wip notes, then take the commit back but keep the file STAGED (reset --soft)
- Now throw the staged wip.txt away completely, file and all (reset --hard)
- Commit scratch.txt as Add scratch notes, then undo the commit leaving the file in your folder but NOT staged (default reset)
Commit graph (live)
Working tree, staging area, repository
m1
m1
m1
f1
f1
f1
f2
f2
f2
new or untracked differs from the area to its right in sync
Stuck, or something not working? You can come back to it any time.
CHECK YOUR UNDERSTANDING
Which is easier to audit on shared history: reverting a bad commit or silently moving the branch back?
Chapter reference: math, worked example & code
This section belongs to the whole chapter, so it is the same on every lesson in it and may cover a different lesson's topic. The code for the experiment you just ran is inside the experiment itself.
Math and notation
Model history as a directed acyclic graph. A commit is reachable from a branch when following parent edges reaches it. A merge preserves both ancestry paths; rebasing creates a different path with new commit identities.
Worked example
Let B have parent A, C have parent B, and D have parent B. A merge M with parents C and D makes both C and D reachable. Moving a branch from C to M is different from copying D's files over C: the graph also records why those histories meet.
Reference code
The following example uses the Python standard library and does not call a remote service.
parents = {'A': [], 'B': ['A'], 'C': ['B'], 'D': ['B'], 'M': ['C', 'D']}
def ancestors(commit):
seen, todo = set(), [commit]
while todo:
node = todo.pop()
if node not in seen:
seen.add(node)
todo.extend(parents[node])
return seen
assert ancestors('M') == {'A', 'B', 'C', 'D', 'M'}
Python examples are provided for study and local execution; this page does not execute Python.
Common mistakes
Git commit does not automatically upload data. Rebase changes commit identities. Reset modes have different effects on uncommitted work.