1

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

1 Answers1

3

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)

IMSoP
  • 89,526
  • 13
  • 117
  • 169
  • That was very descriptive, thank you. Which option would you prefer in this case? Merging with an additional commit or the rebase option? Or would you use a different git workflow to avoid this scenario altogether – Kalindu Prabash Dec 02 '20 at 16:45
  • @KalinduPrabash It's really a matter of taste - some teams like to have a neat linear history, others like to see where the different branches were merged in. The type of workflow you're using also makes a difference to which options make sense. – IMSoP Dec 02 '20 at 16:49