Search code examples
githubgit-merge

Merging multiple feature branches into master without additional commit message?


Suppose you have a master branch:

A--B--C

Feature 1 Branch:

A--B--C--D

Feature 2 Branch:

A--B--C--E

When we do a git merge Feature1 into master, it merges fine, however when trying to merge Feature2, we are presented with vi asking us to enter a commit message for the merge. Is there a way to merge these branches without having extra merge commits? They share the same history from master aside from the feature commit.

The final history on master should look like:

A--B--C--D--E

depending on which commit date (D or E) is first


Solution

  • Background

    • In git, the history is built up by recording the parents of each commit - generally, a "normal" commit has one parent, and a "merge commit" has two, but there can actually be any number of parents, including zero.
    • Every commit is identified by a hash of both its content and its metadata - that includes who committed it and when, and its list of parents. You can't change any part of that data without getting a new commit hash, so all commits are effectively immutable.
    • A "branch" in git actually just points to a single commit, and git follows history backwards from there.

    The scenario, as git sees it

    Each commit points at its parent or parents, and each branch points at a commit.

    Note that the angles on this graph don't mean anything, they're just to lay it out in 2D.

              +--D <--(feature1)
              v
    A <--B <--C <--(master)
              ^
              +--E <--(feature2)
    

    The fast-forward merge

    By default, git will "fast-forward" history whenever it can. What this means is that it just moves the branch pointer without touching any commits at all.

    This is what you see when you merge your first feature branch: git fast-forwards the "master" pointer to point at commit D, and leaves everything else alone:

                 +--(master)
                 V
              +--D <--(feature1)
              v
    A <--B <--C 
              ^
              +--E <--(feature2)
    

    Which (remembering that angles don't mean anything) we can also draw like this:

    A <--B <--C <--D <--(master, feature1)
              ^
              +--E <--(feature2)
    

    The merge commit

    When we come to merge the second feature branch, we can't just fast-forward any more - pointing "master" at commit E would lose commit D. So git's other option is to create a "merge commit" - a commit with more than one parent. The pointer for "master" can then point to this new commit.

    This is why you're prompted for a message on your second merge, because git is creating a new commit (let's call it "M2") so both D and E will be in its history:

    A <--B <--C <--D <--(feature1)
              ^    ^
              |    |
              |    M2 <--(master)
              |    |
              |    v
              +----E <--(feature2)
    

    Which we might also draw like this:

                   +--(feature1)
                   v
    A <--B <--C <--D <--M2 <--(master)
              ^         |
              |         v
              +---------E <--(feature2)
    

    Note that we could have forced git to do this with the previous merge as well, using git merge --no-ff, which would have given us something more like this:

              +----D <--(feature1)
              |    ^
              v    |
    A <--B <--C <--M1 <--M2 <--(master)
              ^          |
              |          v
              +----------E <--(feature2)
    

    Rebase

    So, how do we make a history that looks like this?

    A <--B <--C <--D <--E <--(master)
    

    On the face of it, we can't: E's parent is recorded as C, not D, and commits are immutable. But what we can do is create a new commit which looks like E but has D as its parent. This is what git rebase does.

    After fast-forwarding feature 1, we had this:

    A <--B <--C <--D <--(master, feature1)
              ^
              +--E <--(feature2)
    

    If we now git rebase master feature2, git will create a new version of all commits reachable from feature2 which aren't already reachable from master. It will try to create commits which apply the same changes, and by default copy commit messages and even the original author and timestamp, but they'll have new parents.

    It will then point feature2 at these new commits; in our case, the result will look something like this:

    A <--B <--C <--D <--(master, feature1)
              ^    ^
              |    +--E2 <--(feature2)
              |
              +--E
    

    The original commit E is now not reachable from any branch, and will be cleaned up. But now we can avoid the merge commit: the new commit E2 is in a position where we can fast-forward master again:

    A <--B <--C <--D <--(feature1)
                   ^
                   +--E2 <--(feature2)
                       |
                       + <--(master)
    

    To redraw:

                   +--(feature1)
                   v
    A <--B <--C <--D <--E2 <--(master, feature2)