How merging works

A merge combines the work of two branches. You always merge into the branch you are on: switch to the branch that should receive the changes, then name the branch to bring in. The other branch is not changed. Which kind of merge Git performs depends on one question: has the receiving branch moved since the other one split off?

Top: a fast-forward merge. Commit 6d5c9c2 has parent 5a75fd4; main pointed to 5a75fd4 before the merge and to 6d5c9c2 after it, without a new commit. Bottom: a three-way merge. Commits c8dae00 on main and 901e838 on acl-update both have parent 5a75fd4, the merge base. The merge commit 2dd2d3c has both as parents, and main points to it. Fast-forward merge 5a75fd4 6d5c9c2 main before main after main moves ahead, no new commit Three-way merge 5a75fd4 merge base c8dae00 901e838 2dd2d3c merge commit main acl-update
Hashes from the examples below; arrows point from a commit to its parent. A fast-forward only moves the branch pointer. A three-way merge creates a merge commit with two parents.

Fast-forward merge

In a fast-forward merge, the branch you merge into has no commits of its own since the other branch started, so its tip is an ancestor of the other branch's tip. There is nothing to combine: Git simply moves the branch pointer forward to the other branch's tip. No new commit is created and the history stays a straight line.

git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c ntp-fix
printf 'ntp server 192.0.2.11\n' >> router.cfg
git commit -q -am "Add second NTP server"
git switch -q main
git merge ntp-fix
git log --oneline --graph
Updating 5a75fd4..6d5c9c2
Fast-forward
 router.cfg | 1 +
 1 file changed, 1 insertion(+)
* 6d5c9c2 Add second NTP server
* 5a75fd4 Add NTP server
* aedb4dc Add edge-01 base config

Updating 5a75fd4..6d5c9c2 names the old and new commit of main, and Fast-forward says no merge commit was needed. The summary below lists the files that changed. Afterward, main and ntp-fix point to the same commit. git merge --ff-only only merges when a fast-forward is possible and otherwise stops with an error; scripts use it to make sure they never create a merge commit by accident.

Three-way merge and merge commits

When both branches have new commits, they have diverged, and moving a pointer is no longer enough. Git then performs a three-way merge: it compares three snapshots — the tip of your branch, the tip of the other branch, and their merge base (below). Every change made on one side only is taken over automatically. Only where both sides changed the same or adjacent lines differently does Git need help.

The result is recorded as a merge commit: a commit with two parents, the previous tip of your branch (first parent) and the tip of the merged branch (second parent). Its message defaults to Merge branch '<name>'; --no-edit accepts that message without opening an editor.

git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c acl-update
printf 'ip access-list MGMT\n permit 198.51.100.0/24\n' > acl.cfg
git add acl.cfg && git commit -q -m "Add management ACL"
git switch -q main
printf 'logging host 192.0.2.20\n' >> router.cfg
git commit -q -am "Add syslog server"
git merge-base main acl-update
git merge --no-edit acl-update
git log --oneline --graph
git log -1 --format='commit %h has parents %p'
5a75fd41be199730678ccec34815f03d40a37c7b
Merge made by the 'ort' strategy.
 acl.cfg | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 acl.cfg
*   2dd2d3c Merge branch 'acl-update'
|\
| * 901e838 Add management ACL
* | c8dae00 Add syslog server
|/
* 5a75fd4 Add NTP server
* aedb4dc Add edge-01 base config
commit 2dd2d3c has parents c8dae00 901e838

Merge made by the 'ort' strategy names the algorithm Git used. ort has been the default since Git 2.34; you rarely need to choose another. The graph shows the two lines of work joining in 2dd2d3c, and the format placeholder %p confirms its two parents. In the same way that HEAD^2 names the second parent (see Exploring History), git log --first-parent follows only the first parents and so shows the history of main itself: its own commits, with each merged branch appearing as a single merge entry.

Always a merge commit: --no-ff

Some teams want a merge commit even when a fast-forward is possible, so the history keeps a visible record that a group of commits came in together from one branch. --no-ff (no fast-forward) forces one:

git init -q netops && cd netops
printf 'hostname edge-01\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
printf 'ntp server 192.0.2.10\n' >> router.cfg
git commit -q -am "Add NTP server"
git switch -q -c ntp-fix
printf 'ntp server 192.0.2.11\n' >> router.cfg
git commit -q -am "Add second NTP server"
git switch -q main
git merge --no-ff --no-edit ntp-fix
git log --oneline --graph
Merge made by the 'ort' strategy.
 router.cfg | 1 +
 1 file changed, 1 insertion(+)
*   dec9f66 Merge branch 'ntp-fix'
|\
| * 6d5c9c2 Add second NTP server
|/
* 5a75fd4 Add NTP server
* aedb4dc Add edge-01 base config

Compare this with the fast-forward example: the same two branches, but now main has a merge commit and the graph shows the branch. GitHub's default "Create a merge commit" button for pull requests behaves like --no-ff; its other options, squash and rebase, are covered on Pull Requests and Code Review.

The merge base

The merge base is the best common ancestor of the two commits being merged: the newest commit that both branches contain, usually the commit where one branch split off from the other. git merge-base main acl-update prints it; in the three-way example it was 5a75fd4, the "Add NTP server" commit.

The merge base is what makes a three-way merge smart. With only the two tips, Git could see that a line differs but not who changed it. Compared with the base, the answer is clear: if a line is unchanged on your side and changed on the other, the other side's version wins; if both changed it differently, that is a conflict. The same idea gives meaning to the three-dot diff git diff main...acl-update: what changed on acl-update since the merge base.

A merge conflict, step by step

A merge conflict happens when both branches changed the same lines of a file, or lines right next to each other, in different ways (or one side changed a file the other deleted). In the example below, Amara moves the edge router's syslog destination to a new collector on her branch, while Sam points the same line at the network operations center (NOC) server on main. Then Sam merges Amara's branch:

git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara, on her branch: move syslog to the new collector
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector" --author="Amara Okafor <amara@example.com>"
# Sam, on main: send syslog to the NOC instead
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git merge new-collector
Auto-merging router.cfg
CONFLICT (content): Merge conflict in router.cfg
Automatic merge failed; fix conflicts and then commit the result.

Git merged everything it could, then stopped. The merge is now in progress: no merge commit exists yet, the conflicting file contains both versions, and git status lists it under "Unmerged paths". Nothing is lost; both commits are unchanged.

Reading conflict markers

Conflict markers are the lines Git writes into a conflicted file to show the competing versions. The same conflict again, now showing the file and the status:

git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara, on her branch: move syslog to the new collector
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector" --author="Amara Okafor <amara@example.com>"
# Sam, on main: send syslog to the NOC instead
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git merge new-collector > /dev/null   # stops with the conflict shown above
cat router.cfg
git status
hostname edge-01
ntp server 192.0.2.10
<<<<<<< HEAD
logging host 198.51.100.20
=======
logging host 192.0.2.25
>>>>>>> new-collector
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   router.cfg

no changes added to commit (use "git add" and/or "git commit -a")
  • <<<<<<< HEAD starts the version of your current branch (here main, Sam's change).
  • ======= separates the two versions.
  • >>>>>>> new-collector ends the version from the branch being merged (Amara's change).

Lines outside the markers merged cleanly. With the setting merge.conflictStyle diff3 Git also shows the merge base's version between ||||||| and =======, which often makes the intent of both sides obvious:

git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara, on her branch: move syslog to the new collector
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector" --author="Amara Okafor <amara@example.com>"
# Sam, on main: send syslog to the NOC instead
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git config merge.conflictStyle diff3
git merge new-collector > /dev/null   # stops with the conflict shown above
cat router.cfg
hostname edge-01
ntp server 192.0.2.10
<<<<<<< HEAD
logging host 198.51.100.20
||||||| 451c1e5
logging host 192.0.2.20
=======
logging host 192.0.2.25
>>>>>>> new-collector

Now it is clear that both sides replaced the old 192.0.2.20, each with a different address. (Git 2.35 added zdiff3, a variant that moves identical lines out of the conflict region; many people set it globally.)

Resolving the conflict

Resolving means deciding what the file should contain. That is a human decision, often a quick conversation: here, which collector should edge-01 log to? The answer can be one side, the other, or a combination. Then:

  1. Edit the file to its final content and remove all marker lines.
  2. git add <file> marks it as resolved (it moves from "Unmerged paths" to "Changes to be committed").
  3. git commit creates the merge commit; --no-edit keeps the prepared message Merge branch 'new-collector'.
git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara, on her branch: move syslog to the new collector
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector" --author="Amara Okafor <amara@example.com>"
# Sam, on main: send syslog to the NOC instead
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git merge new-collector > /dev/null   # stops with the conflict shown above
# the team agrees: the new collector it is
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.25\n' > router.cfg
git add router.cfg            # mark the conflict as resolved
git commit --no-edit          # finish the merge with the prepared message
git log --oneline --graph
[main 41a3f93] Merge branch 'new-collector'
*   41a3f93 Merge branch 'new-collector'
|\
| * 28b7522 Move syslog to new collector
* | f7e581f Send syslog to NOC server
|/
* 451c1e5 Add edge-01 base config

In practice you edit the file in your editor rather than with printf. When one side should win for a whole file, git checkout --ours router.cfg or git checkout --theirs router.cfg takes that version (ours is the branch you are on, theirs the branch being merged), followed by git add.

Changing your mind: git merge --abort

If a conflict turns out to be bigger than expected, or you merged the wrong branch, git merge --abort cancels the merge in progress and restores the files to the state before the merge:

git init -q netops && cd netops
printf 'hostname edge-01\nntp server 192.0.2.10\nlogging host 192.0.2.20\n' > router.cfg
git add router.cfg && git commit -q -m "Add edge-01 base config"
# Amara, on her branch: move syslog to the new collector
git switch -q -c new-collector
sed -i 's/192.0.2.20/192.0.2.25/' router.cfg
git commit -q -am "Move syslog to new collector" --author="Amara Okafor <amara@example.com>"
# Sam, on main: send syslog to the NOC instead
git switch -q main
sed -i 's/192.0.2.20/198.51.100.20/' router.cfg
git commit -q -am "Send syslog to NOC server"
git merge new-collector > /dev/null   # stops with the conflict shown above
git merge --abort
git status --short
cat router.cfg
hostname edge-01
ntp server 192.0.2.10
logging host 198.51.100.20

git status --short prints nothing (the working tree is clean again), and router.cfg contains Sam's version as before. Abort works best when you start the merge with a clean working tree; uncommitted changes from before the merge may not be restorable. That is why it is good practice to commit or stash before merging. Once the merge commit exists, abort is no longer possible; then the tools on Undoing Things apply.

Avoiding conflicts

Conflicts are a normal part of teamwork, not a sign that someone did something wrong. But they cost time, and big ones are easy to resolve incorrectly. Most of them can be prevented:

  • Merge small and often. A branch that lives for a day touches few lines and rarely collides. Bring the latest main into a longer-running branch regularly, so you meet conflicts early, in small pieces.
  • Keep commits focused. Do not mix a reformatting of a whole file with a functional change; reformatting touches every line and conflicts with everyone.
  • Talk. If two people need to change the same config at the same time, agree who goes first.
  • Structure files for parallel work. One file per device, site or service conflicts far less than one huge file everybody edits.
  • Agree on formatting. Consistent line endings, indentation and an automatic formatter remove a whole class of meaningless conflicts.

Merge tools

For larger conflicts, a graphical tool helps. Most editors, such as Visual Studio Code and JetBrains IDEs, recognize conflict markers and offer buttons such as "Accept Current", "Accept Incoming" or "Accept Both", or a three-pane view with both sides and the result. git mergetool opens a configured external tool (for example vimdiff or meld) for each conflicted file, one after another. GitHub can resolve simple conflicts in a pull request in the browser. Whatever the tool, the steps underneath are the same: edit the file, mark it resolved, commit.