Rewriting Git history with Rebase: Part II
This is part two of a two parter on rebase vs merging with Git. You can read part 1 right here.
Rebase and squash with Interactive rebase
To “squash” in git means to combine multiple commits into one. This is a common practice in large code bases where it is important to be able to navigate and read git history easily. This is something developers can choose to do at any point but is most commonly used just before merging branches.
At TextNow, we rebase and squash all changes that we merge into main in an effort to keep our git history organized and concise. In most cases a particular feature or a bug fix will correspond to a single commit in main which, makes tracking changes much easier and allows us to easily identify all the changes in any given release. This is not a hard-and-fast rule however, as there are situations where a particularly large feature needs to be broken down into multiple commits.
When it comes to squashing it is important to note that there is no dedicated command -- rather it can be achieved through git command options, or through a combination of commands. There are a few ways squash commits but, the most common is probably interactive rebase alongside a pull request merge option through code hosting platforms such as GitHub, GitLab, or Bitbucket. Some git GUI clients like SourceTree and Tower also support commit squashing.
Git rebase interactive refers to a git rebase command invoked with the -i or -interactive option. Without this option git will automatically apply all the commits in the feature branch to the head of the passed in branch (or main in the case of the earlier example). Running git rebase with interactive flag begins a rebasing session. In this case instead of simply applying all the commits to the branch target branch, we can first cleanup git history by removing, splitting, or combining commits and rewriting commit messages where appropriate.
When the session begins the default text editor the tool configured by your shell’s $EDITOR variable opens. Different commands can be used for each commit to be rebased. For each of the commits to be rebased, one of the following options can be used. To squash the feature commits of the earlier example we would use the reword command on the first commit to change the commit message and then use the fixup command on the rest to meld them into the previous one, discarding their commit messages.
Rebase interactive session sample session[/caption]
Rebase and squash with soft reset and merge-base
There is another way to rebase and squash by using soft reset in combination with git merge-base like this:
This allows us to roll back to where branch A and branch B share a commit -- typically the point where branch A was created from branch B. Adding the --soft option nets us the added benefit of staging any commits after the common commit between branches, unlike the --hard option which drops the commits.
By committing all the staged changes, we're effectively squashing and rebasing on top of the starting commit, similar to rebase interactive, where we specify which commits to squash and which commit messages to keep or change. As an added bonus, this approach is a good opportunity to check that nothing has been committed by accident such as TODO comments or print statements, and to provide a meaningful commit message.
To summarize, using this command we are able to place all the changes in the branch back in stage, review them, and commit them in a single commit that will be placed at the tip of the branch we are rebasing against. Now let's break this down a bit more.
Git merge-base command takes two arguments: Branch B is what you are rebasing against, and branch A is the branch with your changes (main and feature branch in the earlier example). Git merge-base finds the best common ancestor between two commits but it also takes a branch, in which case it is equivalent to the last commit on that branch. The result of merge- base command is a commit where the two given branches were the same, or their divergence point.
We then use this result to rollback to that point using soft reset. Soft reset rolls back to the given commit but keeps the changes as staged changes. Hard reset on the other hand, drops the changes instead. We are now ready to commit all our changes in a single commit which be placed at the tip of the branch we are rebasing against.
This command is similar to git rebase interactive assuming that we are dealing with a simple scenario where we just need to squash all the commits into one commit prior to merging. As with any other rebase operation we will will need to force push these changes to the remote since we have rewritten git history. Because this is a destructive operation, it's a good idea to know how to undo it if something goes wrong. If you have pushed your changes to remote prior to performing this operation you can simply hard reset to origin.
Remember, if you have already committed the changes but haven’t yet pushed to remote, you can always delete your local version of the branch and re-checkout to restore it to the previous state and try again. The above branch is the branch we are trying to rebase and squash. If, however, we have not pushed to the remote we can still undo the soft reset using git reflog and git reset. Git reflog command gives you a list of reference logs, or "reflogs" as they are known, that record when the tips of branches and other references are updated in the local repository. To undo the soft reset with merge-base described earlier we would need to find the reflog for just before the reset and do a hard reset as follows:
This moves head back once, essentially undoing the last operation. Thus, we don’t really need to use git reflog if we are undoing the last operation only, but it is good practice to check if we are not sure.
If merging and squashing sounds like your idea of a good time, take a look at our job openings, and join Maria and our team in our mission to democratize communication.