Git Rebase vs Git Merge: Never Force Push Again

June 27, 2023

Doing a force push after or while getting code reviewed causes pain for both you and your reviewer. Avoid force pushes by using git merge instead of git rebase.

Table of Contents

Motivation

Here’s a scenario: You’ve made a feature branch. You worked on some code. Now you put it up for a pull request on GitHub. Then, I go to review your pull request. I make some comments. You make some fixes. While you’re at it, you notice you had a merge conflict, so you rebase onto latest main to get your branch up to date.

Now when I try to review the changes you made since my last review, which is what GitHub tries to do for me when I click the GitHub notification that tells me there’s an update on a pull request I’m reviewing, I’ll be greeted with:

"We went looking everywhere, but couldn't find those commits." Force pushing is bad!

"We went looking everywhere, but couldn't find those commits." Force pushing is bad!

I can probably go back to the pull request history to figure out what commits are new, and just check those, but why give me that extra work? Especially when using git merge will make your life easier too?

Why you shouldn’t use git rebase

Rebasing is bad. Well, ok, maybe not bad, but it’s almost never what you want.

When you do git rebase, you’re ditching your current commits and recreating them by applying the patches one-by-one starting at another commit. Your original commits are forgotten and replaced by the new ones, each with new commit hashes and commit times etc.

The most common thing I see is using git rebase origin/main to get a feature branch up to date with the main branch. In my best MS Paint (ok I lied, I used GIMP) artistry, it looks something like:

Before `git rebase origin/main`: The `feature` branch was created at commit `B` on `main`

Before git rebase origin/main: The feature branch was created at commit B on main

After `git rebase origin/main`: The `feature` branch recreated at tip of main (commit `D`). Original `E` and `F` commits are replaced with `E'` and `F'`.

After git rebase origin/main: The feature branch recreated at tip of main (commit D). Original E and F commits are replaced with E' and F'.

When you push that branch back up to GitHub, it’s going to be missing the original E and F commits, replacing them with new commits E' and F' with the same patches. It’s like you deleted your whole branch and recreated it from the ground up, this time parenting it to D instead of B. This is why you have to force push with git push -f - you’re effectively replacing the entire branch contents with a completely different branch with the same changes!

Once you force push, since those original E and F commits are gone, GitHub drops them. Then, when GitHub features or anything else reference them (eg. when using “View changes since last review” or if someone linked to a commit in a review), the notice pops up that it can’t find those commits. This makes life painful for your reviewers, who want to see the progress and not re-review the whole pull request every time you want to get up to date with main.

What’s more, you’re making life more difficult for yourself! Because you have to recreate each commit one-by-one at the new tip of main, if there are any conflicts, you might end up having to resolve them multiple times!

This can happen if your feature branch touches the same line multiple times in different commits, and that line was changed on main causing a conflict. Every commit you have in your branch that touches that line will trigger a conflict. You’ll have to resolve it over and over again to finish your rebase.

In summary, if you use git rebase:

  • You make life harder for reviewers by deleting commits
  • You make life harder for yourself when resolving conflicts

Why you should use git merge

The alternative to git rebase origin/main is git merge origin/main. This will create a new commit on your branch that merges all the changes main has had since you last merged or since you branched off of it. There’s different strategies for how git implements merges to get the resulting commit, but the default is a 3-way merge algorithm. The essential idea is the algorithm finds a mutual ancestor commit for the current branch and the branch being merged, and then compares the changesets against that to see if chunks from the changes are in conflict or can be merged automatically. The same sort of thing happens in the git rebase case when replaying the patches one by one, however when doing a git merge the entire merge is a single patch so conflicts only have to be resolved once.

A git merge origin/main might look like this other MS Paint (erm, GIMP) drawing:

Before `git merge origin/main`: The `feature` branch was created at commit `B` on `main`

Before git merge origin/main: The feature branch was created at commit B on main

After `git merge origin/main`: The `feature` branch has a new "merge" commit that resolves the patches from `C`, `D`, `E`, and `F` and has both `F` and `D` as parent commits. All conflicts are resolved in `G`.

After git merge origin/main: The feature branch has a new "merge" commit that resolves the patches from C, D, E, and F and has both F and D as parent commits. All conflicts are resolved in G.

Note that in the git merge scenario, the commits are still there! All we did is add one more new commit. When pushing that up to GitHub, reviewers can see the changes since the last review, and still see the old commits. They aren’t even disturbed by merge commit changes when looking at the pull request files. GitHub is smart enough to only show changes against the base branch of the pull request, so what might have seemed like a huge patch for a commit doesn’t even show up at all. Life is good!

In summary, when using git merge:

  • Life is easier for reviewers since there’s no deleted commits and the merge commit is transparent.
  • Life is easier for you since you only have to merge and resolve conflicts once to get up to date.

Caveats and FAQs

What about a tidy commit history?

The most common complaint against merge commits is that they’re “too confusing” and that people prefer a linear commit history to a complex graph of merges. As this is only really relevant for the main branch, the best way to resolve this is to merge pull requests using a single squash commit by selecting “Squash and merge”:

Using "Squash and Merge" is rebasing with all the commits squashed into one

Using "Squash and Merge" is rebasing with all the commits squashed into one

As repository admin, you can control how pull requests are allowed to be merged:

There's a repository setting to enforce "Squash and Merge"

There's a repository setting to enforce "Squash and Merge"

It’s worth noting that this loses all the individual commits from your feature branch. If you’re sticking with GitHub, however, the pull request will still be around, and the commit message will reference it and its clickable in the GitHub UI, taking you to the pull request. I also recommend setting the commit message defaults to “Default to pull request title and description” here:

Setting the default commit message to "pull request title and description"

Setting the default commit message to "pull request title and description"

What about dropping commits?

Instead of using git rebase and dropping commits to undo them, use git revert. This will make a new commit with the inverse patch from the commit, so no commit history is rewritten but it still undoes the changes!

This won’t do if you’re trying to remove something from existence on GitHub, eg a leaked secret or password! GitGuardian has a good cheatsheet for removing secrets.

What do I do if my local feature branch and the remote feature branch have diverged?

Let’s say you have three commits on your local branch: A, B, C, D and so on through Y. You push it up, but then while poking around you reset your head all the way back to B to see if that fixes something, and then absentmindedly make a new commit Z on B. You can’t do a git push now without git push -f to force it, because your branch and the remote branch have diverged! Your local has one commit, your remote has 23 commits.

When things get really complicated, sometimes it’s easiest to just force push and let everyone deal with it. But if you’re curious, there’s a way to fix this without a force push.

First, save the Z commit hash somewhere, we’ll need it later. Then, git reset the feature branch to origin/feature. Our Z commit is still around until garbage collection time, don’t worry - it’s just orphaned from the branch now. Now that we got all those other commits we don’t want back, we can undo them in one commit by executing git revert --no-commit C..Y followed by git commit with some message about reverting. Finally, you’ll want to git cherry-pick Z to reapply Z at the tip of the branch like we wanted. Now you should be able to push without a force push!

# get up to date
git fetch
# reset to the origin
git reset origin/feature
# revert all the commits we don't want
git revert --no-commit C..Y
git commit -m "Reverting back to B"
# add our new commit
git cherry-pick Z

If there’s no (or minimal) conflicts between Z and all of C..Y, you could avoid the git reset and git cherry-pick and do git pull --rebase instead, which will rebase just Z on top of your feature branch (same as the git cherry-pick does), and then do the git revert --no-commit C..Y after that. This will put Z before the revert commit, unlike the above strategy which puts it after, and might lead to additional conflicts.

# rebase our local commits on top of what remote has
git pull --rebase
# revert all the commits we don't want
git revert --no-commit C..Y
git commit -m "Reverting back to B"

Is it ever ok to git rebase?

Here’s my short list of times it’s ok to git rebase:

  • Using “squash merging” to merge pull requests into main.
  • Rebasing before you’ve put a pull request up for review.
  • Rebasing to rewrite history to hide sensitive info.
  • Rebasing because the original base doesn’t exist anymore.

That last exception could use some explanation. It’s only a problem because of the first exception. Rebasing bad!

Ok, I’ll admit I use “squash merging” as I like my branches to be sloppy while working on them and tidy in main. But if I were true to my principles, I would use normal merges!

Sometimes, in an effort to keep pull requests small and not let myself get blocked by reviews, I find myself making one feature branch off of another that it depends on. I’ll branch off of main to make feature-1, then add my commits and make a pull request into main. While waiting for a review, I’ll then make another branch feature-2 off of feature-1, and add some more commits, and if that gets done I’ll make a pull request from feature-2 into feature-1.

The resulting branches might look something like this:

`feature-2` is branched off `feature-1`, which is branched off `main` at `B`

feature-2 is branched off feature-1, which is branched off main at B

For reviewers, it doesn’t make much sense to review feature-2 before feature-1, as feature-1 needs to be understood first as a dependency of feature-2. So feature-1 is usually reviewed and ready to merge first. However, if I squash merge and delete the feature-1 branch it before feature-2 can be reviewed and merged, then GitHub will automatically change the base of feature-2 to main, but it will still have all the commits from feature-1. This is because of the squash merge - GitHub thinks those commits are still new and unique, since they don’t exist in main’s history.

`feature-2` looks like `E`, `F`, `G`, `H` on the remote after squash merging `feature-1` (represented by commit `I`) onto `main` and then deleting `feature-1`, as it now has `main` as its base. This can get even hairier if `feature-1` has additional commits since `feature-2` branched that haven't made it back into `feature-2`

feature-2 looks like E, F, G, H on the remote after squash merging feature-1 (represented by commit I) onto main and then deleting feature-1, as it now has main as its base. This can get even hairier if feature-1 has additional commits since feature-2 branched that haven't made it back into feature-2

This would muddy the review for feature-2 as it shows all the changes again that the reviewer already saw in pull request for feature-1. I’ve found the best way to resolve this is to rebase feature-2 onto main. Executing git rebase --onto origin/main feature-1 feature-2 will rebase all the commits on feature-2 that are not on feature-1 (which I’m assuming here still exists on your local repository) onto origin/main. This will require a force push, but arguably you shouldn’t have had feature-2 ready for review until after feature-1 went in so hopefully you’re not causing reviewer pain.

# get latest origin/main
git fetch
# rebase feature-2 onto main from feature-1
git rebase --onto origin/main feature-1 feature-2
# force update pull request
git push -f

The other option here is to get both feature-1 and feature-2 reviewed before merging either, then you can merge feature-2 into feature-1 before squash merging feature-1 into main. However, that can be undesirable as it blocks progress until all the pull requests can go in at once. If there’s any follow-ups for either pull request, it blocks the merge for the other. Also, if you have strict rules about reviews, that might trigger a re-review of feature-1 which would now ask reviewers to review feature-2 again as part of feature-1’s review.

Note that none of this is a problem if you use normal merges to merge your pull requests into main. The commits are simply now in main, so the diff is against main at F

After merging `feature-1`, `main` has a new merge commit `I` with parents `D` and `F`. `F` is still around, so the `feature-2` diff in the pull request looks dandy and swell, even after deleting `feature-1` and changing the pull request base `main`!

After merging feature-1, main has a new merge commit I with parents D and F. F is still around, so the feature-2 diff in the pull request looks dandy and swell, even after deleting feature-1 and changing the pull request base main!

Conclusion

Try not to rebase. It causes force pushes, which annoys reviewers. It replays commits one by one, which will annoy you, the dev.

One last callout: It’s worth noting that force pushing has a chance of overwriting other people’s commits on your branch as well. If you have to do a force push, avoid accidentally overwriting someone else’s commit on your feature branch by doing git push --force-with-lease instead. It’s slightly safer, as it won’t overwrite the remote branch if the actual remote branch has changed compared to your local remote tracking branch (ie. if it has updates your local repository isn’t aware of yet).

Of course, it’s easier to simply not get into situations where you need to force push at all - use git merge instead of git rebase!


Get new posts in your inbox

Profile picture

Written by Marcus Pasell, a programmer who doesn't know anything. Don't listen to him.