Git Basics #3: Branching and Merging — Fast-forward and 3-way

7 min read

In “Git Basics #2: add, commit, status — What the Staging Area Means” we covered how to pick changes and stack commits. Once commits start piling up in a single line, the next question follows soon enough: you want to keep the working code as it is and carry out experimental changes separately. The tool that solves this problem is the branch.

It runs in six parts.

  • #1 What Git is — the snapshot model and the three areas
  • #2 add, commit, status — what the staging area means
  • #3 Branching and merging — fast-forward and 3-way ← this post
  • #4 Remotes — clone, fetch, pull, push, and what origin really is
  • #5 Undoing changes — restore, reset, and revert
  • #6 GitHub and your first Pull Request

This post starts by checking what a branch actually is, then walks through the two ways of joining diverged lines back together, and finishes with the flow of resolving a merge conflict — all hands-on.

What a branch really is — a pointer to a commit #

When people first meet branches, the most common misconception is that creating a branch copies the whole project folder. In Git, a branch is a lightweight pointer to a single commit. Let’s look at the real thing directly.

What a branch actually is
$ cat .git/refs/heads/main
1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b

The branch called main is, in substance, a single 41-byte file containing a 40-character commit hash. No files get copied. That is why creating a branch in Git costs practically nothing, and you can create and delete several a day without any burden.

There is one more pointer on top of this. HEAD is the pointer that records which branch you are currently on. When you make a commit, the branch that HEAD points to advances to the new commit.

Creating and switching branches #

A few commands are all you need for branch operations.

Creating and switching branches
$ git branch                 # list branches (* marks the current one)
* main

$ git switch -c feature-login   # create and switch at once
Switched to a new branch 'feature-login'

$ git branch
* feature-login
  main

You can also create with git branch feature-login and then switch with git switch feature-login, but in practice git switch -c, which creates and switches in one step, is used far more often.

Note
Older material often shows git checkout -b feature-login. checkout bundled two different jobs — switching branches and restoring files — into one command, which caused plenty of confusion, so since Git 2.23 branch switching is switch and file restoration is restore. This series uses the new commands.

Committing only moves the pointer #

What happens if we make two commits on feature-login? The only thing that moves is the branch pointer.

Pointer movement as commits are made
[right after creating the branch]
A --- B --- C   ← main, feature-login (HEAD)

[after two commits on feature-login]
A --- B --- C            ← main
              \
                D --- E   ← feature-login (HEAD)

main stays at commit C, and only feature-login advanced to D and E. If you go back with git switch main in this state, the files in the working directory switch to the snapshot at commit C. This structure is the reason experimental code never touches the main history.

Fast-forward merge — just move the pointer #

The experiment succeeded, so let’s fold the work on feature-login into main. You merge after moving to the branch that will receive the changes.

Fast-forward merge
$ git switch main
$ git merge feature-login
Updating 1a2b3c4..9f8e7d6
Fast-forward
 login.py | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)

The output says Fast-forward. Look at the diagram above again: after the branch diverged, main had no new commits at all. In this case Git does not need to create a new commit — it just slides the main pointer forward to E. This is a fast-forward merge.

After the fast-forward
A --- B --- C --- D --- E   ← main (HEAD), feature-login

History stays in a single line and no merge commit is created. If you want to allow only pointer movement, add the option as in git merge --ff-only feature-login. It aborts the merge when a fast-forward is impossible, so it protects you from unintended merge commits.

3-way merge — merging with three commits #

This time, main also gained a new commit while the branches were apart.

Both sides have advanced
A --- B --- C --- F        ← main (HEAD)
              \
                D --- E    ← feature-login

Moving a pointer is no longer enough. Git compares three commits — the common ancestor C, F at the tip of main, and E at the tip of feature-login — and creates a new commit that contains both sides’ changes. Because it uses three commits as its material, this is called a 3-way merge.

3-way merge
$ git switch main
$ git merge feature-login
Merge made by the 'ort' strategy.
 login.py | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)
After the 3-way merge
A --- B --- C --- F --- M   ← main (HEAD)
              \       /
                D --- E     ← feature-login

The newly created M is the merge commit. What sets it apart from a regular commit is that it has two parents (F and E). The history keeps the trace of diverging and rejoining exactly as it happened.

Conflicts — when both sides changed the same part #

In a 3-way merge Git combines most changes automatically. If the two sides changed different files, or different parts of the same file, there is no problem. There is exactly one case it cannot combine on its own: when both sides changed the same part of the same file differently.

A conflict occurs
$ git merge feature-login
Auto-merging greeting.py
CONFLICT (content): Merge conflict in greeting.py
Automatic merge failed; fix conflicts and then commit the result.

At this point Git pauses the merge and leaves markers in the conflicted file.

Conflict markers inside greeting.py
<<<<<<< HEAD
print("Hello, this is School of Web")
=======
print("Welcome, this is School of Web")
>>>>>>> feature-login

Reading them is simple. From <<<<<<< HEAD to ======= is the content of the current branch (main), and from ======= to >>>>>>> feature-login is the content of the branch being merged in. Resolving means deleting this whole block, markers included, in your editor and writing the final code you want to keep. You can pick one side or blend the two into something new.

Finishing up after resolving
$ git add greeting.py     # mark it as resolved
$ git commit              # complete the merge commit (a message is suggested automatically)

A conflict is not an error. It is Git asking a question: a human decision is needed here, so please decide. Clean up the markers, answer with add and commit, and the merge is complete. If you want to abandon the merge midway, git merge --abort takes you back to the state before the merge.

Delete branches that are done #

A branch pointer whose merge is finished has nothing left to do. Leaving it around does not delete any commits, but the list gets cluttered, so deleting is a good habit.

Deleting a merged branch
$ git branch -d feature-login
Deleted branch feature-login (was 9f8e7d6).

-d is the safe option that only deletes branches that have already been merged. There is also -D, which force-deletes unmerged branches, but it can lose commits, so use it only when you know exactly what it means.

Tip
There is also a method called rebase that tidies diverged history into a single line without a merge commit. It comes with decision criteria and taboos of its own, so this basics series leaves it out — the upcoming series on practical workflows will tackle it head-on.

Wrapping up #

Three key points from this post.

  • A branch is a 41-byte pointer to a commit, so creating and deleting one costs practically nothing.
  • There are two kinds of merges. If only one side has advanced, fast-forward simply moves the pointer; if both sides have advanced, 3-way builds a merge commit from three commits including the common ancestor.
  • A conflict is the procedure that requests a human decision when both sides changed the same part, and it ends with cleaning up the markers followed by add and commit.

So far everything has happened inside your own computer. In the next post, “Git Basics #4: Remotes — clone, fetch, pull, push, and What origin Really Is”, we look at how to exchange a repository with other computers, and what origin — a name many of us use without ever learning its identity — actually is.

X