Skip to main content

Command Palette

Search for a command to run...

Rebases in Git and why you shouldn’t be afraid of them

Updated
9 min read
Rebases in Git and why you shouldn’t be afraid of them
M

I'm JS developer with 13 years of professional experience. I'm always happy to teach my craft.

Beginners often experience several points of confusion about rebases when using Git:

  • what they are

  • why people do them

  • why pushes might fail after successful rebase

In this article, I’ll provide answers to those questions. In the meantime, TL;DR: rebases are a way of making your crude commit history into something you’ll want to share with the rest of your team.

How to do a rebase

To perform a rebase, you run git rebase <commit-reference>. Commit reference can be anything—for example:

  • branch name,

  • tag, or

  • commit id.

A rebase is a fairly complicated operation, so let’s walk through various aspects of it.

Simple rebase

In its simplest form, rebasing takes changes from one place (one base), and moves them to another. It changes the spot where the history branched off. So with the tree alias I wrote about previously, we start with a branch graph like this:

$ git tree
* 293b722 (HEAD -> test) add test.txt file
| * abc01e7 (origin/main, origin/HEAD, main) Add lorem ipsum to readme
|/
* edd3504 Add readme

and we move our branch to start from a different place—the top of the main branch:

$ git rebase main
Successfully rebased and updated refs/heads/test.

and as a result, we get a tree like this:

$ git tree
* fe4254e (HEAD -> test) add test.txt file
* abc01e7 (origin/main, origin/HEAD, main) Add lorem ipsum to readme
* edd3504 Add readme

Our test branch used to start at edd3504 Add readme, and now it’s starting at abc01e7 Add lorem ipsum to readme.

Rebase in progress

Even a simple rebase may require additional input from your side. For example, you might have some conflict to resolve:

$ git rebase main
Auto-merging test.txt
CONFLICT (add/add): Merge conflict in test.txt
error: could not apply 293b722... add test.txt file
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".
Could not apply 293b722... add test.txt file

When checking the status in such a situation, you will get an instruction from Git on how to proceed:

$ git status
interactive rebase in progress; onto a03989b
Last command done (1 command done):
   pick 293b722 add test.txt file
No commands remaining.
You are currently rebasing branch 'test' on 'a03989b'.
  (fix conflicts and then run "git rebase --continue")
  (use "git rebase --skip" to skip this patch)
  (use "git rebase --abort" to check out the original branch)

Unmerged paths:
  (use "git restore --staged <file>..." to unstage)
  (use "git add <file>..." to mark resolution)
        both added:      test.txt

no changes added to commit (use "git add" and/or "git commit -a")

You need to edit the file to resolve the conflict. When you are done with it, you add the change to staging:

$ git add test.txt
(no output)

and you continue with the rebase:

$ git rebase --continue
[detached HEAD b81cad4] add test.txt file
 1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/test.

Subsequently, Git will continue with the rebase: it can finish smoothly as in my case, or you may encounter more conflicts to resolve manually.

Rebase interactive

A more advanced option for rebase is rebase interactive. It allows you to make very precise changes on the branch—not just move it from one place to another. Let’s take a look at a few examples below.

Reword commits

The simplest thing you can do is just change the commit message. The good thing is that there is no chance of conflict because the files are left the same.

Squashing commits

You can choose a few commits and turn them into one. It preserves the file's changes—it just collapses a chain of changes into one commit. It requires writing a new commit message for the new commit, but on its own it will not cause conflicts.

Editing commits

You can edit the file changes in the commit too. This allows you to make sure the commits contain all the relevant changes. If there are changes to the same places later in the branch, then you will need to resolve conflicts manually.

Removing commits

Another operation is removing commits from the branch—the commit and its changes. If there were additional modifications to the same code later in the branch, removing the commit will cause some conflicts. The most basic use case is when you realize that some changes are not needed, and you want to remove them from the history. Besides that, I often use it when I realize my branch is getting too big, and I intend to make it more focused and merge it sooner.

Reordering commits

You can reorder commits as well. There are situations in which it can make sense, but it gets complicated quickly if you try to reorder a commit that changes the same area of code.

File interface

As you can see, interactive rebasing requires a lot of subtle input. Git gets this input in the form of a text file. When I run git rebase main -i, I get my editor with the following content:

pick a03989b add test.txt

# Rebase abc01e7..a03989b onto abc01e7 (1 command)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's log message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified); use -c <commit> to reword the commit message
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#

When you save the file and exit the editor, Git will follow the instructions you provided there. It’s an interface that can feel awkward at first, but one can get used to it pretty quickly. The only “gotcha” is that you need to know your default editor, and know how to use it. I’ve written a bit more about it in a section of another article.

Practical tip

While doing interactive rebasing, try to do one thing at the time. Git can manage to do the following in one rebase:

  • change the branch starting point

  • reorder some commits

  • squash others

  • etc.

However, you’ll probably get lost in all the conflicts that will happen all at once. It’s easier to do one thing at the time and keep the following order:

  • remove superfluous commits first,

  • squash related commits, and then

  • move the branch to another base.

In this way, you reduce the number of commits that have to be moved around—thereby reducing the number of conflicts you must resolve.

History changes

Rebasing changes the history of the repository. This means that the same changes will appear as different commits on different branches. It’s not a mistake, but it can be confusing when you see the following logs for the first time:

$ git push origin test
To github.com:how-to-js/git.md.git
 ! [rejected]        test -> test (non-fast-forward)
error: failed to push some refs to 'github.com:how-to-js/git.md.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details

non-fast-forward means there is no direct route way for one point in the repository history toward another. In this case, you need to integrate remote and local changes yourself. There are some subtleties to getting branches in sync, and it’s a topic for another article.

Depending on the workflow in your project, it can be forbidden or required to override the history of some branches. The workflow I use prohibits history changes to the master/main branch, and it requires rebases of all the other branches. There are no right or wrong approaches to rebasing: each policy has its pros and cons, but it’s likely that your team will insist that you maintain consistency with the selected approach.

Possible side effects

Besides the intrinsic complexity of rebases, they introduce some challenges in team environments:

local-only branches

When your work is on your computer only, it’s always safe to rebase or change the history in any other way. It’s a good practice because it always helps you to clean up your work before others can see it. If you started some work a month ago and since then, you’ve made changes to main regularly—it’s not worth preserving information with commits such as Merge remote-tracking branch 'origin/main' into test. Instead, you can rebase regularly and keep history as a straight line.

Branches that were uploaded to remote

Once you share your branch, any change to its history can break stuff. For example, your continuous integration (CI) will remember the results of a test run of a commit, but the commit will be gone. I’ve met people who are very cautious about changing a history that was already shared, so I guess there are teams where this might be discouraged.

Remote branches that other works on

The most complicated case is to change the history of a branch that other people are working on. For that, I would recommend:

  • making sure everybody is up-to-date when the change happens—that there are no two people introducing changes to the branch at the same time;

  • getting everyone updated to the new, rebased branch before they start the new work in it.

This is a delicate situation because it breaks a lot of the automated conflict resolution Git employs. It’s good to be careful in such cases and make sure everybody knows what’s happening.

Keep on learning

If you are interested in learning more about branches in Git, a great (and pretty) resource is available at Learn Git Branching. If you are interested in learning more about Git, sign up here to get updates about my Git-focused content.

E

Good post! I'd like to share an additional perspective when it comes to rebasing.

If you started some work a month ago...

In this case and many others I often do the following:

  1. Checkout main
  2. Checkout a new branch
  3. Cherry pick the commits from my old branch onto the new branch

Another way to do this:

  1. Checkout your old branch
  2. Do a git reset --soft HEAD~<number of commits you have added>
  3. Create a new commit and copy the SHA
  4. Repeat the steps above

These are simple ways to copy your commit history without dealing with some of the challenges that can come with collaborative rebases.

Probably the most common time I use this workflow is the following:

Developer A is working on feature A and opens a merge request.

Developer B needs code from feature A, and is in a dilemma of how to begin. Do they copy the code they need from feature A? Do they branch off feature A branch?

I recommend Developer B branches off Feature A branch. They can do their work and commit along the way.

When Feature A merges (probably a squash merge) the real problem enters. Developer B now has a bunch of commits from feature A in the Feature B branch that no longer exist in main due to the squash.

This can really challenge peoples git knowledge... but it's quite simple really. It's as easy as 1, 2, 3.

  1. Checkout main
  2. Checkout a new branch for Feature B
  3. Cherry pick your commits from Feature B branch onto your new feature B branch

Again, I think if there are many commits you are better off doing an interactive rebase to squash the feature B commits into a single commit (as long as the history isn't important.). Or the lazy way, `git reset --soft HEAD~<commit count> (or alternatively, git reset --soft <SHA of the original HEAD commit of the main branch>). Then you create a new commit, copy the SHA, and cherry pick that onto your new branch.

In general I pretty strongly discourage force pushes on my teams to branches that have been pushed up to a remote. Squash and merge being enabled means that the commit cleanliness in main is preserved, and the commits in a branch can be as messy as they need to be (reality in development is rarely beautiful... why rewrite history anyways?) Very rarely are perfectly curated commit histories worth the time for a developer. I'd rather see beautiful code and a nice squash merge than a perfect few commit messages in a PR history. If you follow that line, then it's fine to have some merge commits in there anyways.

All in all, a great article. Just thought I'd share my thoughts on this as well. I stumbled here from the gitlab CI monorepo post. It will be a useful starting point for me tomorrow :).

What do you think Marcin Wosinek?

M

Thank you for your comment!

The cherry-picking instead of doing rebase is a great technic—I use it myself as well. I learned it as a workaround for complicated rebases, and somehow it feels more advanced to me.

So far, I didn't have a chance to try teaching it to someone who doesn't know rebase—it would be interesting for me to see which one is easier to grasp. My guess is that rebases can get ugly in the situations you mention, while cherry-pick requires more understand of Git.

why rewrite history anyways?

My main objective with it is to keep rebasing as main is updated.

Very rarely are perfectly curated commit histories worth the time for a developer

Yeah, there are always trade-offs.

My starting point:

  • I expect all my colleagues to be fluent with Git enough, that curating commit history is just a moment for them;
  • letting history be messy saves their time, while sometimes costing my time (to figure out what's happening etc.)
  • I got us to iterate on such a small change-sets that there is rarely a need to put more than 2 or 3 meaningful commits to the branch.

There are strong arguments for very different policies when it comes to force pushes. I think often it's mostly a matter of esthetic preference of the team—or of the most senior person on the team.

1
E

Marcin Wosinek Definitely agree. Thanks for sharing your perspective as well!

The git reset --soft was a new command for me to pick up this year and I've found it very useful. Sometimes if you have a super complex merge you can also just use:

  1. git diff HEAD origin/main >> changes.patch
  2. git checkout main
  3. git checkout -b feature-branch
  4. git apply changes.patch

After all, a bunch of commits are just a text file with changes, and you can pipe it into a file, and apply it arbitrarily.

Patch files tend to be the most useful when I'm sharing in progress changes with another developer and I don't want them to deal with committing or asking someone to check out. I just take my in progress 75% working code and:

  1. git diff HEAD origin/main >> changes.patch
  2. Send changes.patch over slack to developer
  3. Ask them to checkout main and git apply changes.patch

Most often I actually see this when working with Solutions engineers, client engineers, or sales engineers. It's an easy way to verify if the thing you are solving for them is actually accurate. That depends on those team members being actual developers though... but I digress.

It may be interesting to do a blog post on squash merges. When discussing requiring squash merges with developers I tend to reference

christopher.xyz/2020/07/13/squash-merge.html

and

blog.dnsimple.com/2019/01/two-years-of-squash-merge/

This one is non-negotiable for me as far as enforcement goes, but I have learned that others disagree, sometimes strongly. It would be interesting to see more blog posts on this topic in the future, regardless of stance!

1

More from this blog

H

How to dev

164 posts

Articles about programming. JavaScript and general advice for beginners in the industry.