Introduction
Git rebase is a powerful tool that can help you to move or combine one or more commits onto a new base commit, rewriting the project history to make your branch appear as it was created from another commit. This helps maintain a cleaner and more linear history. In this article, we will cover the fundamentals of rebasing branches, explore some common pitfalls, provide real life examples, and suggestions of how to deal with them.
Keep in mind that this article focuses on Git, not on collaborative tools for Git repositories like Github or Gitlab, except for common concepts such as code approvals, pull requests, remote repository, that we will mention as they come up.
That said, let's get started.
Git Rebase: The basics
Why would someone do a rebase if there is a git merge
command? There are scenarios when rebasing is more useful than merging. For instance, let's say you have been working on a new feature on a separate branch for some time. During that time, the master branch has progressed with new commits that your working branch does not have, because it was created before those new commits were added.
This situation is very common when you work with other people. Also, rebasing helps to maintain a cleaner history of the project and facilitates troubleshooting when a bug appears in a feature that was previously working fine.
I personally prefer the interactive tool for doing a rebase. If you are not familiarized with it, you can use it by adding the -i
or --interactive
flag, e.g. git rebase -i master
to rebase your working branch with master.
With the interactive mode, I take the opportunity to squash my commits into the few as possible. What do I mean by that? If you are working on a small or medium-sized ticket, it is often unnecessary to keep all the commits you made. Instead you can combine them into a single commit and give it a descriptive name.
For example:
// Prefer a single commit on the master branch
e184504 (HEAD -> master) feature A
// instead of separate commits that belong to the same feature
e754500 (HEAD -> master) add Z
714fb43 add Y
8147fb0 add X
The interactive tool allows to perform different operations during the rebase like pick, drop, reword or squash. If you want to learn more about this interactive mode, check this resource: rewriting history
Imagine that you are working on a project where the master branch has two commits:
e754500 (HEAD -> master) add About page
714fb43 add Login page
Then, you created a feature-1
branch from master branch and worked on it. Here is the history for the new branch:
3ac2d3d (HEAD -> feature-1) fix bug with contact service
0a298d8 add fix bug in contact form
f91cc77 add Contact page
e754500 add About page
714fb43 add Login page
After you got the necessary approvals on Github, the branch is ready to be merged. However, you found that other team members are being merging changes into the master branch while you were working on feature-1.
cdf9f3f (HEAD -> master) add Home page // feature-1 does not have this commit
902add0 add Blog page // feature-1 does not have this commit
e754500 add About page
714fb43 add Login page
This is where git rebase comes into play. First you need to pull the latest changes from master, by checking out master and executing git pull origin master
. Then, if you type git rebase master
a normal rebase you can make your working branch to look similar to this:
c1fbb22 (HEAD -> feature-1) fix bug with contact service
85d4677 add fix bug in contact form
701da95 add Contact page
cdf9f3f (master) add Home page
902add0 add Blog page
e754500 add About page
714fb43 add Login page
Note: Github and Gitlab have options for doing this in the same website.
With the interactive mode you can use the squash option to combine your commits into a single commit. You can achieve it by changing the word pick for squash or the letter s
:
pick c1fbb22 fix bug with contact service
squash 85d4677 add fix bug in contact form
squash 701da95 add Contact page
That will combine three commits from your working branch into a single commit:
fb8036f (HEAD -> feature-1) add Contact page
cdf9f3f (master) add Home page
902add0 add Blog page
e754500 add About page
714fb43 add Login page
Now you can merge your branch without any issues.
If you want to learn more about rebasing, I wrote other article some time ago: Guide to deeply understand merge rebase squash and cherry-pick.
Sometimes there will be conflicts during the rebase. When that happens, Git will ask you to resolve them first. If things go wrong during the rebase, you can always execute git rebase --abort
to cancel the operation and start over. After you resolved all the conflicts, you can continue the process by typing git rebase --continue
.
Now let's dive into a more complex situation when things have gone really wrong.
Undoing a Committed Rebase
What happens if, for some reason, you dropped a commit that should have not been deleted, or squashed a commit that you did not wanted to squash. One situation where this might happen is when you need to refactor a service or a class to prepare it for your new feature, and you were not expecting it.
Ideally, you should create a separate branch for this refactor and open a Pull Request, so that your co-workers or team lead can review the refactor first, avoiding unnecessary noise to the feature. I encourage you to do that whenever possible.
However, let's say that you decided to keep the refactor in the same branch. If you make a mistake like dropping or squashing the wrong commits, a git revert will not help you, because they will not appear in the history anymore.
Let's break this down with an example:
You have been working on a new feature for a project. During the development, you encountered a blocker, some legacy code that needs to be refactored to support your feature. Let's say that you kept it in the same branch, with the plan to create a separate branch for it later, once your feature is finished.
Your history looks like this:
d600d8a (HEAD -> feature-1) fix bugs related to feature 1
37356d2 initial implementation of feature 1
92829c9 (refactor) prepare codebase for feature 1
fb8036f other commit
You are ready to push your changes to the remote repository. But first, you need to squash your commits, to maintain your history clean. I know that it does not make much sense if the branch has just two commits. But in a real scenario there could be a lot of commits in your working branch.
Thus, you decided to rebase your branch to fetch the latest changes from master and take the opportunity to squash your commits during the process, giving them a descriptive name. You did this by typing git rebase -i master
.
Here is the output of that command:
pick 92829c9 (refactor) prepare codebase for feature 1
pick 37356d2 initial implementation for feature 1
pick d600d8a fix bugs related to feature 1
However, by accident you told Git to combine your feature with the refactor:
pick 92829c9 (refactor) prepare codebase for feature 1
squash 37356d2 initial implementation for feature 1 # this one should have remained as "pick"
squash d600d8a fix bugs related to feature 1
After saving your changes with :wq
, git allows you to change the commit name if you want. Now the history looks like this:
404824f (HEAD -> feature-1) add feature 1
fb8036f other commit
And then, you noticed your mistake. The refactor commit is gone. The three commits were combined, and you only intended to combine the last two. You can always pull your changes from the remote repository and restore your branch, if and only if you pushed your changes previous to rebase your branch. To make it worse, let's say you pushed your changes with git push -f
. Now we are in trouble.
However, we can still try one more thing.
Note: Be careful. If you pull the remote branch after a rebase you can loose your progress on the local branch, specially if you did not pushed your changes previous to the rebase.
Git Reflog
Something interesting about Git is that it is difficult to really delete things. Even if the deleted commits are no longer visible in the history, there is a change that we can still access them by checking the reference logs, also known as reflog.
The reflog records almost everything that you do in the local repository, and provides more information than commands like git log
. We can access the reflog by typing git reflog show
. Here are part of my reflogs:
404824f (HEAD -> feature-1) HEAD@{0}: rebase (finish): returning to refs/heads/feature-1
404824f (HEAD -> feature-1) HEAD@{1}: rebase (squash): add feature 1
df8d688 HEAD@{2}: rebase (squash): # This is a combination of 2 commits
92829c9 HEAD@{3}: rebase (start): checkout master
d600d8a HEAD@{4}: commit: fix bugs related to feature 1
37356d2 HEAD@{5}: commit: initial implementation for feature 1
92829c9 HEAD@{6}: commit: (refactor) prepare codebase for feature 1
fb8036f HEAD@{7}: checkout: moving from master to feature-1
...
Based on that, we can infer that the rebase occurred between HEAD
and HEAD@{2}
. Additionally, HEAD@{3}
, HEAD@{4}
, HEAD@{5}
and HEAD@{6}
points to the changes before the rebase. You can safely check out any of those commits. Just keep in mind that any checkout operation also generates entries in the reflog, which means the HEAD@{n}
reference will change for a particular commit. You can checkout the commit by its hash as well.
Note: According to the official documentation, the reflog has a default expiration of 90 days.
After inspected those commits with git checkout
, it seems that the best candidate is HEAD@{4} (d600d8a)
. Let's restore our branch.
There are multiple ways to do it. One approach would be to delete the working branch and recreate it from d600d8a
by typing:
git checkout d600d8a
git branch -D feature-1
git checkout -b feature-1
Alternatively, you can reset your working branch with git reset --soft d600d8a
. I personally prefer this approach, but use whatever works better for your specific case.
After the git reset, here are the history logs:
d600d8a (HEAD -> feature-1) fix bugs related to feature 1
37356d2 initial implementation for feature 1
92829c9 (refactor) prepare codebase for feature 1
fb8036f other commit
That is it. Now we have the commits previous to the rebase, and since we learned from our mistakes, we proceed to push our changes to the remote repository before even start the rebase. After that, we can safely rebase the working branch and do what we need to do. In this case, we wanted to squash the last two commits and keep the refactor as a separate commit. Now the history looks like this:
d600d8a (HEAD -> feature-1) add feature 1
92829c9 (refactor) prepare codebase for feature 1
fb8036f other commit
Finally, once we are 100% sure that the rebase was successful we can push the changes to the remote repository.
Conclusion
Git is a powerful and versatile tool, but its flexibility can lead to human errors. It is important to know those common errors, how to prevent them and deal with them if necessary.
In this article we learned how to combine commits and rewrite the log history using git rebase. We also explored some examples where things can go wrong and introduced git reflog as an method to restore deleted commits in our working branch.
Bibliography
Atlassian | 2024 | Ref And The Reflog
Atlassian | 2024 | Rewriting History
Git Reference | 2024 | Git Reflog
Git Reference | 2024 | Git Tools Rewriting History
Jodaut | 2023 | Beyong-the-basics-guide-to-deeply-understand-merge-rebase-squash-and-cherry-pick
Top comments (0)