Pull Requests: Out of Date with Main
What that message means on GitHub
Sometimes you see this pop up on a PR:
The dropdown options are these:
If other PRs have been merged, where the main pointer points is different from where it used to point. That’s what we mean when we say “main has moved”.
The impact is that your PR isn’t mergeable in its current form, because CI/CD has only tested your code on top of the “old version” of main, which isn’t what we will actually get after merging your PR.
The basic problem is that two PRs can each pass on their own and still break main when they’re combined.
So the branch protection rules for main specify that we need an updated version of the commit history that combines your changes with the current version of main, and that has to pass CI/CD before we are permitted to merge the code. This is part of the “continuous integration” (CI) part of CI/CD; we need to be continuously integrating new changes with the main branch and testing them thoroughly before merging them.
So, to get it mergeable, we need to either do a “merge” or a “rebase”.
Ok, what’s the short version of what I need to do?
Typically, you just click the button, take the default (merge), wait for CI/CD to run again, and then the problem is solved.
That’s usually enough, but if you want to understand what’s happening, and the difference between “merge” and “rebase”, read on.
The mental model of “branches”
Before we can talk about why this is happening, and what the difference is between a merge and rebase, we have to make sure we understand what we mean by “branch”.
Developers that are new to working with branches often have a very incorrect mental model about what a branch is.
- A branch is a “pointer to a commit”.
It is nothing more, and nothing less.
When the message above arises, it is because the main branch pointer has “moved” since the time you created this branch; it points to a different commit than the one that is the parent of the commits in your branch.
But isn’t the branch “all of the commits”?
No. To repeat: a branch is just a pointer (literally in the data structures and algorithms sense of “pointer”) to a single commit.
The word branch sounds like it refers to a whole “collection of commits”, and sometimes it is informally used to mean this.
In this sense of the word, it typically means:
- start from the commit the branch points to
- follow parent links (keeping in mind that merge commits can have more than one parent)
- until you reach some other commit (often
main, or whatmainused to be when the branch was created)
But this is not, technically, the definition of branch, or how it is implemented, and this mental model can interfere with understanding how commands such as the following work:
git mergegit rebasegit reset
To understand these commands properly, you need to start with the mental model that: a branch is a pointer to a commit, identified by its SHA.
It’s really just a handy name that we give to a SHA, the same way in a C++ program, we might have p or head or tail be a variable that stores the address in memory of some piece of dynamically allocated memory, or a reference in a Java program is handy name for the location of some instance of java.lang.Object.
Ok, so what’s merge vs. rebase
The starting point
Suppose we start with this situation:
In the diagram above
Mis the commit thatmainpointed to before you started your work.AandBare the commits for your PRN1andN2are commits that have been added to themainbranch in the meantime, so nowmainpoints toN2, notManymore.
This means that CI/CD:
- was last run against whatever main was at the time of that run (here,
M) - has not been run on the combination of your code with the current
main(N1andN2)
What do the buttons do
Neither button moves main. Both only move the feature pointer. The difference is which commit it ends up pointing to.
Update with merge commit
- GitHub creates one new commit,
U, with two parents:B(your old tip) andN2(currentmain). featuremoves fromBtoU. SinceUis a child ofB, this is a normal forward move.AandBare unchanged and keep their SHAs.Mstays in your branch’s history as the place where you forked.- Local effect: a plain
git pullon the branch fast-forwards toU.
Update with rebase
- GitHub replays your changes on top of
N2. This creates new commitsA′andB′. They have the same diffs and messages asAandBbut different SHAs, because their parents are different. featuremoves fromBtoB′.Bis not an ancestor ofB′, so this is a force-push style update.AandBstill exist, but no branch points to them anymore.Mis no longer special for your branch. As far asfeatureis concerned, it’s just an ordinary commit inmain’s history.- Local effect: your laptop’s
featurestill points toB, so the two copies have diverged. A plaingit pullwill either complain or create a messy merge. You needgit fetchfollowed bygit reset --hard origin/feature, orgit pull --rebase. In practice, this is the main reason to prefer the merge option.
Either way, N2 (current main) is now an ancestor of feature, and that’s what makes GitHub stop saying “out of date.” CI then re-runs on a commit whose content includes your work plus N1 and N2.
The new value of main (after the PR is merged)
Which of these you get depends on the repo’s merge button setting, not on how the branch was updated:
| After “update with merge” | After “update with rebase” | |
|---|---|---|
| Create a merge commit | new main = P, parents N2 and U | new main = P, parents N2 and B′ |
| Squash and merge | new main = S, parent N2 | new main = S, parent N2 |
| Rebase and merge | linear: M–N1–N2–A″–B″ (U is dropped) | linear: M–N1–N2–A″–B″ |
The other button: “Create a merge commit”, “Squash and merge”, “Rebase and merge”
The Update branch button only moves your branch pointer. The big green button at the bottom of the PR is different: it’s the one that moves main. Its dropdown can offer up to three merge methods.
Where these are set
A repo admin turns them on or off under Settings → General → Pull Requests, with the checkboxes Allow merge commits, Allow squash merging, and Allow rebase merging. Only the methods that are enabled show up in the dropdown. If more than one is enabled, whoever clicks the button chooses. Branch protection rules or rulesets can narrow this further. For example, Require linear history rules out merge commits. If a repo uses a merge queue, the queue picks the method instead.
All three examples below start from the “Update with merge commit” picture above: feature points to U.
Create a merge commit (the default)
M---N1---N2------P <- main
\ \ /
A---B-----U <- feature
GitHub makes a new commit P with two parents: N2 (where main pointed just before the merge) and U (your branch). main moves to P. Every commit, including A, B and U, keeps its SHA and stays in main’s history.
- Pro: nothing is rewritten;
git log --first-parent mainshows one entry per PR. - Con: history fills up with merge commits, including the “update branch” ones.
Squash and merge
M---N1---N2---S <- main
GitHub makes one new commit S whose parent is N2. S contains all of your PR’s changes together. main moves to S. A, B, and U are not in main’s history at all.
- Pro: a clean history with one commit per PR, and how you updated your branch doesn’t matter.
- Con: the individual commits are gone from
main. Because your branch’s commits are not ancestors ofmain,git branch -d featuremay refuse to delete it, saying the branch is “not fully merged” (use-D). If you keep working on the same branch and open another PR, the old commits reappear in it. Delete the branch after a squash merge and start a new one frommain.
Rebase and merge
M---N1---N2---A''---B'' <- main
GitHub replays each of your commits on top of N2 and moves main to the last one. The history is linear, with no merge commit. These are new commits with new SHAs, even if you had already rebased, because GitHub always rewrites the committer info.
- Pro: linear history that still keeps each individual commit.
- Con: SHAs in
maindon’t match the ones on your branch, so the same “start a fresh branch” advice applies.