Don't Fear the Rebase ๐Ÿฎ๐Ÿ””

I'm ashamed to report that this title is not unique, but I felt it in my soul, so I needed to keep it. Please forgive me. ๐Ÿ˜…

If you use git, you likely have experienced the pain of merge conflicts. Due to the decentralised nature of git, merge conflicts are inevitable given that two people work on the same file and area of the codebase.

A checkout and status command showing a divergence - a potential conflict!

There are ways to address merge conflicts, prepare for them, or outright avoid them, so let's try to use these tools and strategies to improve our collaboration with others ๐Ÿ’ช.

One of these tools is git rebase.

I want this blog post to address one primary thing:

๐Ÿ™ˆ
Rebase is Scary

This blog post won't leap frog your git skills to that of the master of ancients, but it will hopefully give you a mental model to use and inject some confidence to start failing with safety and fallbacks, moving from the above to:

๐Ÿ’ก
I can try Rebasing

Aborting

Many operations in git can be aborted and you will go back to where you were. If you attempt to git merge and something scary happened, just abort! Then you can prepare, backup (pushing to a new branch, for example), and collect yourself. git merge --abort

abort!

The Reflog

We're going to look at the reflog first. It is an invaluable tool whenever you have "lost" some file or changes, because, oftentimes, you haven't! As long as you have added and committed something, the reflog will be able to find it, even if you deleted the branch.

reflog of a small project

If you type git reflog in your terminal, (press q to escape! Like git log) you will see the reference log. The docs in the 2nd / 3rd sentence give a good summary of what this is, and why it is so, so, useful:

Reflogs are useful in various Git commands, to specify the old value of a reference. For example, HEAD@{2} means "where HEAD used to be two moves ago"

If you haven't directly seen it yet, HEAD is just a reference to the tip of the branch you are on. You can act on HEAD with commands like git show HEAD which prints the commit and diff of your last commit, and you can git diff HEAD to show what changes on your local filesystem there are, or even git diff HEAD --cached to see what is the diff between the staged files (the ones that are green in git status) and the last commit. You can even list all the commits between two references with git log and the .. syntax with git log trunk..HEAD or git log main..HEAD depending on what you name your main branch. ๐Ÿ’†โ€โ™‚๏ธ

The reference log records all changes to HEAD and other references. It's basically the audit log of git. And we can access and copy SHAs from the audit log by using git reflog and copying the SHAs we need to restore our HEAD back, potentially to a time before we attempted a merge or rebase - which is a neat party trick if anything. Well, a special, nerdy, git party. ๐Ÿฅณ

It's worthwhile to mention that reflog has a lot of information, so sometimes it is easier to reason about a log or a diff command. Even if the log command is a bit more complicated, it reads nicely with that .. syntax we discussed above. That extra bit of information a well specified log or diff command has is just the extra bit you need to realise why something is conflicting:

# Show me a list of commits on a branch
git log main..feat-2

# show me a list of commits to where i am now
git log main..HEAD

# show me a diff between two branches
git diff main feat-1
git diff feat-1 feat-2
git diff main feat-2
Log and Diff examples

Fast Forward Merges

Our overall goal is to be able to use git with more confidence, and hopefully less merge conflicts! So to that end, before rebasing, what if we could just use git merge and/or git pull in a 'safer' way that wouldn't cause merge conflicts?

Enter - Fast Forwarding โฉ

Fast Forward Merges are a merge strategy to constrain what git is allowed to do to your working tree when merging. A "fast forward" means there is a linear path without any branching or conflicts, so the whole lineage of changes can be "fast forwarded" to the new end state. You may have seen this when git is warning you about it's merge strategy, some message like this.

warning: Pulling without specifying how to reconcile divergent branches is discouraged. You can squelch this message by running one of the following commands sometime before your next pull:

ย git config pull.rebase false ย # merge (the default strategy)
ย git config pull.rebase true ย  # rebase
ย git config pull.ff only ย  ย  ย  # fast-forward only

You can replace "git config" with "git config --global" to set a default preference for all repositories. You can also pass --rebase, --no-rebase, or --ff-only on the command line to override the configured default per invocation.

This is nothing to be afraid of! It actually has both of the main concepts we'll cover in this blog post (fast forward and rebase), but in general, this is just asking you how do you want pull to work? (maybe we should land a PR to simplify that whole message to that one liner question ๐Ÿ˜œ)

The default strategy is pull.rebase false. It may have been the cause of that 11:59pm assignment upload to turn it in .com (am I dating myself?) where you were fighting a merge conflict and just gave up and sent your own local copy of the code instead of what was merged into main. ๐Ÿ’€

Whenever you run git pull, the pull.rebase false strategy will merge the changes you have fetched from the origin (ie github) into the changes you've made, it is no different than if the remote branch and your local branch were entirely different branches. It has its uses, and is the default most folks (including myself) are used to. The reasoning being, when executing git pull, I fully expect git to first fetch all the changes, and then attempt to merge them in. It's what it's done since forever. But it is important to recognize that pull.rebase false means git pull does two operations. git fetch then git merge.

If you want to avoid conflicts with git pull, consider turning on the pull.ff only config flag, as that will make pulling disallow conflicts.

git config pull.ff only

If you try to pull something that would cause a conflict, it will now fail, as you have told git to only do fast forward merges. This is the same as if you did a git merge and then a git merge --abort.

Resolving Impossible Fast Forwards

If this happens, what do you do? This is my recommendation:

  • Branch off of your branch and push to a new remote branch (take note of the commit, or if multiple this branch name)
  • Check out main
  • Delete your original branch
  • Checkout the remote branch (dbl check the SHA on github/gitlab/origin match your local copy)
  • Use the reflog to cherry pick commit(s) on top of this branch, resolving smaller conflicts one by one. (alternatively, you can manually re apply the changes using your new temporary branch as a reference if it small)
โ”
A cherry-pick is a way to apply just a single commit to HEAD - it's kind of like a surgical merge of a single commit to the branch you are currently on. ๐Ÿ”ช

For example, the above aborted feat-2 example has one commit that conflicts, but going through the process looks like

# backup our changes before merging
git checkout -b feat-2-san-local-backup
git push -u origin feat-2-san-local-backup:feat-2-san-local-backup

# align our local with what our coworkers pushed to feat-2
# this should get feat-2 to match the remote origin (github/gitlab)
git checkout main
git branch -D feat-2
git pull
git checkout feat-2

# get the commit we need
git reflog

# induce a focused conflict we can fix with cherry-pick
git chery-pick fff265694ace4d75f4c9366df29b4ff648f34405
vim src/pages/index.astro # fix the conflict

Instead of reflog, you could also just show the commits on the branch with a log command: git log main..feat-2-san-local-backup which will have a lot less output and sometimes just has the one commit you need.

Instead of inducing a conflict with cherry-pick , you can elect to open up your change in a UI like github, for example you could have GitHub make a diff between any two branches like this, and then manually do you changes on the shared branch, and then push a fresh commit - avoiding merge conflicts entirely - at the expense of you copying/pasting or redoing a bit of work.

Rebase

Rebasing means to change the base of your branch to some other starting point. Rebasing rewinds all your changes (storing them safely in a stack), then checks out to move to the new base (usually a newer version of main / trunk), and then re-applies your local rewound changes onto the new base.

๐Ÿ’ก
If you notice, the resolution suggestion I gave above for the fast-forward only strategy does something very similar, if not identical!

This is useful because it can pre-empt any merge conflicts, prevent merge conflicts by preparing a way for fast forward merge, and helps keep main tidy. Pre-empting merge conflicts doesn't mean there won't be any, but if you rebase often, the scope of the merge conflict is contained.

You don't even need a new branch, if you git fetch and your git status is reporting conflicts, you can use the origin/feat-2 style of reference to rebase your local changes on top of the branch from your origin (ie your latest local copy of the state of the branch that github/gitlab etc have). It's a good idea to add the -i flag to make it an interactive rebase if you are using origin/ style references for branches that have more than 1 / 2 commits, for reasons we will dive into in the next section.

๐Ÿ‘€
You may notice some shortened commands like "gc" or "gs", those are shell aliases that I list out below for git checkout and git status.

This may still incur conflicts, but you will be much more in control of what is happening, and they will happen one at a time, commit by commit, which often means they are easier to reason about, even if it feels like an onslaught of conflict after conflict (and can be a bit repetitive).

Interactive Rebase

Rebase has an option -i to go into interactive mode, which allows you to tell git what to do when applying each of your rewound changes. This is very powerful as it allows you to re-order, re-word the commit body or message, and squash your commits.

When you submit the command, you are presented with a text file to edit - when you save+close this file, git will execute the rebase based on this list of instructions. You are kind of programming git here, in rebase syntax. The commented out section tells you about the commands, and you can edit the first word of each line with a single letter or the full command word. The command letter or word plus the git SHA is all git needs, the title is there for us humans.

Especially if you use the origin/ reference style I strongly recommend you use an interactive rebase. This is because if you share commits you likely want to drop them before inducing needless merge conflicts. Many times the rebase rewinding and finding the common ancestor works flawlessly, and you won't see duplicate commits - but if someone else has force pushed or rebased or the lineage has differentiated, then it's a good idea to at least review what the interactive rebase lists as the commits to apply after switching to the new base, and looking at the new base on your remote server (ie GitHub) to see if anyone has squashed things that you still have expanded.

drop the first commit, the new base already includes this

In the above screenshot, I had checked GitHub and saw that the branch I was rebasing onto squashed its commits after rebasing onto main, I had already squashed my commits onto main in a different way, so I now need to drop my first squash, to not conflict with the origin's squash, and edit my second squash to only include the non-conflicting parts.

To be clear - this kind of situation is likely to be quite rare for the latest versions of git, as most of the time git can tell what has happened, but it can save a lot of pain by doing that extra step when you see a divergence in git status after a fetch or failed pull.

When you tell git rebase to do something like an edit/squash/fixup command, git may find conflicts or may pause to let you do that action. The simplest one is just rewording a commit, which you will be presented with a git commit like screen and move on. But for editting or fixup-ing, you will have to tell git rebase when you are done working on the commit.

You do this by doing whatever the rebase or git status message says, which is often just a git add / git commit / git rebase --continue.

Conclusion

Feel free to drop a question wherever you found this post, happy to discuss and amend! Happy git-ing ๐Ÿ’ป, here is a quick reference of some of the commands we used:

# the reflog, git's audit trail
git reflog

# log .. syntax with HEAD, and branches
git log main..HEAD
git log main..feat-1
git log feat-1..feat-2
git log origin/feat-1..feat-1

# diffs
git diff feat-1..feat-2
git diff origin/feat-1..HEAD
git diff main..HEAD

# fetch stuff
git fetch

# change strategies
git config pull.ff only
git config pull.rebase false
git config pull.rebase true

# rebasing
git rebase
git rebase -i
git rebase -i origin/feat-1
git rebase -i main
git rebase -i feat-2

git rebase --continue

# may be necessary - push force with lease
git push --force-with-lease

## BONUS shell + git aliases!!

alias gs='git status'
alias gc='git checkout'

# git rebase shell aliases
alias gbase='git rebase'
alias gbae='git rebase'
alias grc='git rebase --continue'

# get current branch
alias gcb='git branch | grep "*" | cut -d" " -f2'

# push to the origin and track a new remote branch
function gpuocb () {
	local branch=$(gcb)
	git push -u origin $branch:$branch
}

# don't miss out on things!
alias gfomo='git fetch origin main:main'

# Bae Git Aliases for typos
git config --global alias.bae rebase
git config --global alias.rebae rebase