What does a clean commit history look like? Let’s take a look at a the commit history of the main branch of a repository like Poetry. Two things stand out to me – 1. Each commit is bite-sized (atomic commits); changes are limited to a few files and functionalities, and 2. each commit message succinctly describes the change made, so if anything goes wrong, devs know exactly where to look. This is not only helpful for debugging, but it also helps with automated generation of release notes.
A key tool in keeping a clean commit history is git rebase
. But most people I’ve talked to aren’t familiar withit. So I hope these notes will help you learn to use git rebase
like a pro.
The basic rebase
You’re working on the feature
branch, and need to merge it back into main
. Rebase is used to keep the git history linear and synchronize your branch with the additional commits made in F
and G
.
The syntax for rebase is git rebase <target branch>
git pull origin main # update your target branch
git checkout feature # switch to your branch
git rebase main # initiate the rebase
This would have the following effect:
Before After
A---B---C---F---G (main) A---B---C---F---G (main)
\ \
D---E (feature) D'---E' (feature)
In git jargon, we call this “rebase onto main”.
⚠️ After rebasing, you must force push your changes:
git push --force
Why do you need to force push after a rebase? Because the commits in the feature branch has changed (D -> D'
,E -> E'
). You can read the long answer here.
I recommand reading Git rebase illustrated to see what’s happening under the hood. Specifically, having an understanding of what happens when git “rewinds” then “replays” your commits is very helpful in branch management.
Rebasing with 2 arguments
git rebase --onto
allows you to rebase starting from a specific commit. It grants you exact control over what is being rebased and where. This is for scenarios where you need to be precise [1].
Let’s say you need to rebase feature
directly on top of F
starting from E
, dropping D
Before After
A---B---C---F---G (main) A---B---C---F---G (main)
\ \
D---E---H---I (HEAD, feature) E'---H'---I' (HEAD, feature)
In this case, we would use the command git rebase --onto F D
. i.e., “rebase onto F
, starting from the child of D
”.
You can think of it like we are changing the parent of E
from D
to F
. The general syntax here is
git rebase --onto <new_base> <old_base>
Use case: Dealing with squashed parent branches
This syntax is very useful when you have branched off of feature-1
, but feature-1
has been squash-merged into main
:
feature-1 has been squash-merged to main This is equivalent to the previous picture
A---BC <-- main A---BC <-- main
\ \
B---C <-- feature-1 B---C---D---E <-- feature-2
\
D---E <-- feature-2
Now you might want to merge feature-2
to main
as well, but because B
and C
are technically different from BC
, you might run into issues.
To maintain a clean commit history, you can drop B
and C
, and rebase onto BC
:
# main points to BC and feature-1 points to C
git switch feature-2 && git rebase --onto main feature-1
Which would look like
D'---E' <-- feature-2 (HEAD)
/
A---BC <-- main
\
B---C <-- feature-1
\
D---E [abandoned]
Use case: Squashed parent brahcnes with additional commits
Let’s complicate the situation a bit. Someone has made additional commits to feature-1
after you’ve branched off it, then squash-merged feature-1
into main
. To keep your own commit history clean in this case, do the same as above:
git switch feature-2 && git rebase --onto main feature-1
Which looks like
Before After
D'-E' <-- feature-2 (HEAD)
/
A---BCFG <-- main A--BCFG <-- master
\ \
B---C---F---G <-- feature-1 B--C--F--G <-- feature-1
\ \
D--E <-- feature-2 (HEAD) D--E [abandoned]
See detailed discussion here.
Use case: Removing a commit from history
Have you ever pushed a commit by accident? It’s ok, we all have. I’ll show you how to remove that commit from your git history.
Let’s say you’ve made the commit A
, B
, C
, … G
. If you need to remove C
from your history, do the following:
# Assuming you're on the correct branch
git rebase --onto <commit-B> <commit-C>
You’ll get
Before After
A---B---C---D---E A---B---D---E
However, once you push --force
, the git commit history will still show that you’ve force pushed. So the original commits will still be visible.
Rebasing with 3 arguments
We can be even more precise while rebasing. In the examples above, you can specify which branch you’re referring to with
git rebase --onto <new_base> <old_base> HEAD
Doing so would have the same effects illustrated above, where HEAD
points to feature-2
.
The general syntax for 3-argument rebase is
git rebase --onto <new_base> <old_base> <until>
What happens if we change HEAD
to another commit? In the basic example, git rebase --onto F D H
would have the following effect:
Before After
A---B---C---F---G (branch) A---B---C---F---G (branch)
\ | \
D---E---H---I (HEAD my-branch) | E'---H' (HEAD)
\
D---E---H---I (my-branch)
Here, we’ve not only rebased my-branch
, we’ve also removed a commit. The same effect is achieved if we do git rebase --onto F D HEAD~1
.
Neat! But you probably won’t need to use this day to day.
Further reading
These notes are distilled from numerous blogs and stack overflow answers. Select posts worth reading in detail include:
1 - a tutorial on the rebase –onto command
2 - more examples of the –onto command.
Simon Dosda thinks one step ahead and organizes his commits locally even before pushing. Specifically, you can amend local commits.