Skip to content

Instantly share code, notes, and snippets.

@sskeirik
Last active October 13, 2025 14:29
Show Gist options
  • Select an option

  • Save sskeirik/4cdc134e87245385cf5f60bbc3cfc42f to your computer and use it in GitHub Desktop.

Select an option

Save sskeirik/4cdc134e87245385cf5f60bbc3cfc42f to your computer and use it in GitHub Desktop.
GitHub PR Merge Strategies

The following seems to be a technique that lets us control the precise commit history that is generated when a PR is merged. The reason why this matters is that, if we follow this technique, it will simplify our merges in the future.

Git Development Workflow and Fast-Forward Merges

In my mind, in an ideal world, our commit graph would look something like this:

 * <- work-in-progress-feature
 |
  \
   * <- development
   |
   |
    \
     * <- staging
     |
      \
       * <- production
       |

where merges always go from left-to-right.

Then, when we want to cut a new release, we can simply update the branch pointers for each branch to a later branch, i.e., perform a fast-forward merge.

For example, if we want to merge changes from staging into production, we could use a fast-forward merge:

NOTE: The notation branch@{1} indicates the previous value of a branch (before the operation which most recently updated it).

       * <- work-in-progress-feature
       |
        \
         * <- development
         |
         |
          \
staging -> * <- production
           |
            \
             * <- production@{1}
             |

However, github, by default, does not permit fast-forward merges.

Even if you start with this nice commit graph, on GH, by default, if we try to merge staging into production, we will get this graph:

       * <- work-in-progress-feature
       |
        \
         * <- development
         |
         |   * <- production
          \ /|
staging -> * |
           | |
            \|
             * <- production@{1}
             |

If we continue this approach for all of the other branches, we will get this zig-zag graph:

                              * <- development
                             /|
work-in-progress-feature -> * |
                            | | * <- staging
                             \|/|
           development@{1} -> * |
                              | |
                              | | * <- production
                               \|/|
                 staging@{1} -> * |
                                | |
                                 \|
                                  * <- production@{1}
                                  |

On the other hand, if we had used fast-forward merges for each update, we would obtain the graph:

 * <- work-in-progress-feature, development
 |
  \
   * <- development@{1}, staging
   |
   |
    \
     * <- staging@{1}, production
     |
      \
       * <- production@{1}
       |

Default GitHub PR Workflow Explainer

As you all probably know, the typical PR workflow goes likes this:

  • Locally:

    1. Checkout latest primary branch
    2. Create a new branch update for a new feature/bugfix/refactor
    3. Do some work; add commits to update branch
    4. Push the update branch up to the GitHub remote
  • On GitHub:

    1. Create a new PR with base branch primary and PR branch update
    2. Check that tests pass and PR is reviewed
    3. Click the green "merge" button

At this point, the changes from update are merged into primary.

The way that this happens is that GH (almost always) creates a new merge commit, resulting in the zig-zag like graphs above.

Alternate GitHub PR Workflow

This workflow permits us to use the exact commit history that we want.

  • Locally:

    1. Checkout latest primary branch
    2. Create a new branch update for a new feature/bugfix/refactor
    3. Do some work; add commits to update branch
    4. Push the update branch up to the GitHub remote
  • On GitHub:

    1. Create a new PR with base branch primary and PR branch update
    2. Check that tests pass and PR is reviewed
  • Locally:

    1. Perform the merge of branches primary and update; this creates a new a commit on top of the primary branch

      Note: Since you're working on the CLI, you can create a fast-forward merge.

    2. Push the primary branch up to the GitHub remote

      Note: For GitHub repos with branch protection, this is not normally possible. However, in this special case, GitHub dectects that:

      1. The branch has passing CI and is approved by reveiwers
      2. The pushed primary branch now points to a proper merge commit for branches primary and update (presumably, it also checks that the contents of primary are equal to update)
  • On GitHub: At this point, GH will automaticaly close PR and update the branch primary to be the merge commit from the CLI.

GitHub PR Merges

Introduction

When a PR is opened on GitHub, there are three branches/commit references involved:

Branch/Commit Ref Description
base The branch into which changes will be merged
head The PR branch which contains changes to be merged into base
merge-base The most recent common ancestor commit of base and head

In all cases, the goal of the PR is to incorporate all changes from the commit range merge-base..head into base. Let's call the new commit (generated as a result of the merge) base' (which we pronounce as base prime).

Standard PR Merges

Let's consider an example where commit C is base:HEAD and commit D is head:HEAD.

-A---B---C (base)
  \
   `-D---E (head)

In this case, the merge-base is commit A and the goal is to merge changes from D and E on top of commit C. To do this, one of three strategies is used:

  1. Merge

    In this case, a merge commit F is created with parents C and E.

    -A---B---C---F (base')
      \         /
       `-D---E-'   (head)
    
  2. Squash-Merge

    In this case, commits D and E are squashed into a single commit G (represented by the branch squash) and then a merge commit F is created with parents C and G.

    -A---B---C---F (base')
     |\         /
     | `-G---*-'   (squash)
      \
       `-D---E     (head)
    
  3. Rebase

    In this case, commits D and E get mapped to commits D' and E' on top of C (since they may be modified during rebasing).

    -A---B---C---D'--E' (base')
      \
       `-D---E          (head)
    

Fast-forward PR Merges

To avoid merge conflicts, a very common situation is that PRs are created where the merge-base is the base commit, i.e., we have a situation like:

-A---B---C         (base)
          \
           `-D---E (head)

In this common case, there is actually no need to create a merge commit since all of the desired changes are already encapsulated in commit E. However, what GitHub actually does is the following:

  1. Merge

    In this case, a useless merge commit F is create with parents C and E whose contents are equal to E.

    -A---B---C---*---*---F (base')
              \         /
               `-D---E-'   (head)
    

    This is because, in this case, GitHub merges with git's --no-ff option.

  2. Squash-Merge

    In this case, commits D and E are squashed into a single commit G and then base branch is fast-forwarded to commit G.

    -A---B---C---G     (base', squash)
              \
               `-D---E (head)
    
  3. Rebase

    In this case, base branch is fast-forwarded to the head branch so both branches become identical:

    -A---B---C---D---E (head, base')
    

Note: In this special case, we have the property that the contents of base' and head are identical.

What about Merge Conflicts?

Suppose we have the following case:

-A---B---C (base)
  \
   `-D---E (head)

If GitHub cannot apply one of its strategies due to merge conflicts, it does one of the following:

  1. gives up (it does this when conflicts are detected during a rebase or if detected conflicts are too complex);
  2. presents the user with a merge-conflict editor.

If the user chooses to use the merge-conflict editor, once the conflicts are resolved, the resolved conflicts will be merged into the head branch, i.e., we will have:

-A---B---C     (base)
  \       \
   `-D---E-`-F (head')

This is done, as far as I can tell, because, ultimately, the head branch is the one which must pass reviewer/CI validation, so all changes must become part of the head branch for testing. If this new commit head' passes CI validation and obtains the necessary approvals, then it can be merged into the base branch, using either the merge or squash-merge strategy strategies depicted above (as GitHub doesn't perform conflict resolution for rebases).

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment