Git-Mastery: Lessons

T6L2. Merging Branches


Most work done in branches eventually gets merged together.

This lesson covers that part.

Merging combines the changes from one branch into another, bringing their diverged timelines back together.

When you merge, Git looks at the two branches and figures out how their histories have diverged since their merge base (i.e., the most recent common ancestor commit of two branches). It then applies the changes from the other branch onto your current branch, creating a new commit. The new commit created when merging is called a merge commit — it records the result of combining both sets of changes.

Given below is an illustration of how such a merge looks like in the revision graph:

  • We are on the fix1 branch (as indicated by HEAD).
  • We have switched to the main branch (thus, HEAD is now pointing to main ref).
  • The fix1 branch has been merged into the main branch, creating a merge commit f. The repo is still on the main branch.

The branch you are merging into is called the destination branch (other terms: receiving branch, target branch)
The branch you are merging is referred to as the source branch (other terms: incoming branch, merge branch).
In the above example, main is the destination branch and fix1 is the source branch.

A merge commit has two parent commits e.g., in the above example, the merge commit f has both d and e as parent commits. The parent commit on the destination branch is considered the first parent and the parent commit on the source branch is considered the second parent e.g., in the example above, fix1 branch is the source branch that is being merged into the destination branch main -- accordingly, d is the first parent and e is the second parent.

HANDS-ON: Merge a branch (with a merge commit)

Scenario You have a repo with two unmerged branches main and feature1.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "[feature1] f2"
    checkout main
    commit id: "[HEAD → main] m3"
    checkout feature1

Target To practice branch merging, let's merge each branch to the other.

Preparation

Option 1: Create a fresh sandbox using the Git-Mastery app

  • Navigate inside the gitmastery-exercises folder.
  • Run gitmastery download hp-merge-commit command.

The sandbox will be set up inside the gitmastery-exercises/hp-merge-commit folder.


Option 2: Repurpose the sandbox from the previous hands-on practical

You can continue with the sports repo from earlier, which should look like the following. Note that we are ignoring the feature1-alt branch, for simplicity.


1 Switch back to the feature1 branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "[HEAD → feature1] f2"
    checkout main
    commit id: "[main] m3"
    checkout feature1

2 Merge the main branch to the feature1 branch, giving an end-result like the following. Also note how Git has created a merge commit (shown as mc1 in the diagram below).

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout main
    commit id: "[main] m3"
    checkout feature1
    merge main id: "[HEAD → feature1] mc1"
>_  CLI
git merge main
Sourcetree

Right-click on the main branch and choose merge main into the current branch. Click OK in the next dialog.

If a confirmation dialog pops up, choose as follows:

The revision graph should look like this now (colours and line alignment might vary but the graph structure should be the same):

Observe how the changes you made in the main branch (i.e., the imaginary bug fix in m3) is now available even when you are in the feature1 branch.
Furthermore, observe (e.g., git show HEAD) how the merge commit contains the sum of changes done in commits m3, f1, and f2.

3 Add another commit to the feature1 branch, in which you do some further changes to the boxing.txt.

echo -e "Manny Pacquiao" >> boxing.txt
git commit -am "Add Manny to boxing.txt"

Switch to the main branch and add one more commit.

git switch main
echo -e "Lionel Messi" >> football.txt
git commit -am "Add Messi to football.txt"
gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout main
    commit id: "m3"
    checkout feature1
    merge main id: "mc1"
    commit id: "[feature1] f3"
    checkout main
    commit id: "[HEAD → main] m4"

4 Merge feature1 to the main branch, giving an end-result like this:

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    branch feature1
    commit id: "f1"
    commit id: "f2"
    checkout main
    commit id: "m3"
    checkout feature1
    merge main id: "mc1"
    commit id: "[feature1] f3"
    checkout main
    commit id: "m4"
    merge feature1 id: "[HEAD → main] mc2"
>_  CLI
git merge feature1
Sourcetree

Right-click on the feature1 branch and choose Merge.... The resulting revision graph should look like this:

Now, any changes you made in feature1 branch are available in the main branch.

done!

When the destination branch hasn't diverged — meaning it hasn't had any new commits since the merge base commit — Git simply moves the branch pointer forward to include all the new commits in the source branch, keeping the history clean and linear. This is called a fast-forward merge because Git simply "fast-forwards" the branch pointer to the tip of the other branch. The result looks as if all the changes had been made directly on one branch, without any branching at all.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "[HEAD → main] m2"
    branch bug-fix
    commit id: "b1"
    commit id: "[bug-fix] b2"
    checkout main


[merge bug-fix]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "m1"
    commit id: "m2"
    commit id: "b1"
    commit id: "[HEAD → main][bug-fix] b2"
    checkout main

In the example above, the main branch has not changed since the merge base (i.e., m2). Hence, merging the branch bug-fix onto main can be done by fast-forwarding the main branch ref to the tip of the bug-fix branch (i.e., b2).

HANDS-ON: Do a fast-forward merge

Scenario You have a repo with an unmerged branch add-swimming. The main branch has not diverged from the add-swimming branch yet.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "[main] mc2"
    branch add-swimming
    commit id: "a1"
    commit id: "[HEAD → add-swimming] a2"

Target Do a fast-forward merge of the add-swimming branch into the main branch.

Preparation

Option 1: Create a fresh sandbox using the Git-Mastery app

  • Navigate inside the gitmastery-exercises folder.
  • Run gitmastery download hp-merge-ff command.

The sandbox will be set up inside the gitmastery-exercises/hp-merge-ff folder.


Option 2: Repurpose the sandbox from the previous hands-on practical

To continue with the same sports repo we used above, create a new branch called add-swimming, and some commits to it as follows:
Switch to the main branch, create a new branch, switch to the new branch, add a file named swimming.txt, stage it, and commit it.
Do some changes to swimming.txt, and commit those changes.

Here are the equivalent commands:

git switch main
git switch -c add-swimming

echo "Michael Phelps" > swimming.txt
git stage swimming.txt
git commit -m "Add swimming.txt"

echo "Ian Thorpe" >> swimming.txt
git commit -am "Add Thorpe to swimming.txt"

git switch main

Target Do a fast-forward merge of the add-swimming branch.

1 Ensure you are on the main branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "[HEAD → main] mc2"
    branch add-swimming
    commit id: "a1"
    commit id: "add-swimming] a2"

2 Merge the add-swimming branch onto the main branch. Observe that there is no merge commit. The main branch ref (and the HEAD ref along with it) moved to the tip of the add-swimming branch (i.e., a2) and both branches now point to a2.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main (and add-swimming)'}} }%%
    commit id: "more commits ..."
    commit id: "mc2"
    commit id: "a1"
    commit id: "[HEAD → main][add-swimming] a2"

done!

It is possible to force Git to create a merge commit even if fast forwarding is possible. This is useful if you prefer the revision graph to visually show when each branch was merged to the main timeline.

>_  CLI

To prevent Git from fast-forwarding, use the --no-ff switch when merging. Example:

git merge --no-ff add-swimming

Here are two other relevant options for the git merge command:

  • --ff-only: Merge goes ahead only if a fast-forward merge is possible.
  • --ff: Explicitly specify a fast-forward merge is preferred, but allows creating a merge commit if a fast-forward merge is not possible. In fact, this is Git's the default behaviour even if you don't use this option. So, the option is not useful unless the relevant default merge behaviour has been changed previously.
Sourcetree on Windows

Tick the box shown below when you merge a branch:

Sourcetree on MacOS

Trigger the branch operation using the following menu button:

Sourcetree top menu

In the next dialog, tick the following option:

To permanently prevent fast-forwarding:

  1. Go to Sourcetree Settings.
  2. Navigate to the Git section.
  3. Tick the box Do not fast-forward when merging, always create commit.

A squash merge takes all the changes from the source branch and combines them into a single commit on the destination branch. It is especially useful when the source branch has many small or experimental commits that would otherwise clutter history.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "[HEAD → main] m1"
    branch feature
    checkout feature
    commit id: "f1"
    commit id: "[feature] f2"


[squash merge...]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "m1"
    branch feature
    checkout feature
    commit id: "f1"
    commit id: "[feature] f2"
    checkout main
    commit id: "[HEAD → main] s1 (same as f1+f2)"

In the example above, the branch feature has been squash merged onto the main branch, creating a single 'squashed' commit s1 that combines all the commits in feature branch.

After a squash merge, you typically delete the source branch, so its individual commits are no longer kept. The destination branch's history stays linear, as the work done in the source branch is replaced by one commit on the destination branch. As a result, a squash merge commit is just a normal commit, and does not have a 'parent' reference to the source branch.

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "[HEAD → main] m1"
    branch feature
    checkout feature
    commit id: "f1"
    commit id: "[feature] f2"
    checkout main
    merge feature

[If using a regular merge,
with a merge commit]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "m1"
    commit id: "f1"
    commit id: "[HEAD → main][feature] f2"

[If using a fast-forward merge]

gitGraph BT:
    %%{init: { 'theme': 'default', 'gitGraph': {'mainBranchName': 'main'}} }%%
    commit id: "more commits ..."
    commit id: "m1"
    commit id: "[HEAD → main] s1 (same as f1+f2)"

[If using a squash merge, and after
deleting the source branch thereafter]

The mechanics of doing a squash merge is covered in a separate detour.

EXERCISE: branch-bender

EXERCISE: branch-forward

DETOUR: Undoing a Merge

  1. Ensure you are in the .
  2. Do a hard reset of that branch to the commit that would be the tip of that branch had you not done the offending merge i.e., rewind that branch to the state it was in before the merge.

In the example below, you merged main to feature1.

If you want to undo that merge,

  1. Ensure you are in the feature1 branch (because that's the destination branch).
  2. Reset the feature1 branch to the commit that was the tip of the feature1 branch just before you merged the main branch to it.
EXERCISE: merge-undo

EXERCISE: ff-undo


DETOUR: Comparing Branches

Comparing branches in Git is useful when you want to understand how two lines of development differ — for example, before merging a branch, you might want to review what changes it introduces to the main branch.

Here are two ways to compare two branches:

  • Double-dot notation git diff branchA..branchB: Changes based on commits in branchB but not in branchA. This is the one used more commonly.
  • Triple-dot notation git diff branchA...branchB: This shows changes in all the commits that are reachable by either of two references but not by both of them.
    i.e., commits unique to branchA or branchB.
EXERCISE: branch-compare


DETOUR: Doing a Squash Merge

To do a squash merge, you can use the --squash switch. It will prepare the squashed merge commit but will stop short of actually finalising the commit.

git merge --squash feature-1
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested

At that point, you can go ahead and make the commit yourself, with the commit message you want.

EXERCISE: merge-squash