Skip to content

Merging Branches: Fast-Forward, 3-Way, Squash, and Conflicts

A branch lets us develop work separately, but finished work eventually needs to join another line of development. Merge is Git’s operation for integrating the history of one branch into another.

Suppose a backend team finishes a Payment feature on feature-payment and wants to add it to main:

Terminal window
git switch main
git merge feature-payment

The order matters:

  • main is the target or receiving branch because it is checked out.
  • feature-payment is the source or incoming branch named after git merge.
  • A merge normally moves only the target branch. The source branch remains at its existing commit.

Think: “Switch to the branch that should receive the work, then merge the branch that contains the work.”

Names such as main and feature-payment do not determine the merge type. Git examines the commit graph:

  • The commit currently checked out (HEAD).
  • The source branch’s tip.
  • Their best shared ancestor, called the merge base.

Based on that relationship, the default git merge may report that nothing is needed, perform a fast-forward, or create a true merge commit.

If the source commit is already part of the target’s history, the target already contains that work:

A──B──C main
└──── feature-payment

Running this while on main:

Terminal window
git merge feature-payment

produces a message such as Already up to date. No branch pointer or commit needs to change.

A fast-forward is possible when the current target is an ancestor of the source. In other words, the feature grew but main did not move after the feature branch was created.

Before:

A──B main
\
C──D feature-payment

After merging feature-payment into main:

A──B──C──D main, feature-payment

Git only advances the main pointer from B to D:

  • No histories need to be reconciled.
  • No merge commit is created by the default behavior.
  • The existing feature commits are preserved.
  • The files at main now match the snapshot at D.

Use this when policy requires a linear update and the command must refuse if the histories have diverged:

Terminal window
git switch main
git merge --ff-only feature-payment

--ff-only does not rearrange commits. It means: fast-forward if possible; otherwise stop without creating a merge commit.

Case 3: Histories diverged — 3-way merge

Section titled “Case 3: Histories diverged — 3-way merge”

Suppose Payment development continued while main received an urgent Login hotfix:

C──D feature-payment
/
A──B─────E main

Both branch tips now contain commits the other side does not contain. Moving the main pointer directly to D would lose E from the first-parent line, so a normal fast-forward is impossible.

Git performs a 3-way merge using three inputs:

  1. The merge base: shared commit B.
  2. Ours: the current target tip E (main).
  3. Theirs: the incoming source tip D (feature-payment).

If Git can combine the changes, it creates a merge commit:

C──D feature-payment
/ \
A──B────E──────M main

Commit M usually has two parents: E and D. This records that the two development histories were brought together. main advances to M, while feature-payment still points to D until it is moved or deleted.

Sometimes a team wants a merge commit even when a fast-forward would be possible, so the feature boundary remains visible:

Terminal window
git switch main
git merge --no-ff feature-payment

This preserves an explicit “feature merged here” event, but it also creates more merge commits. Whether that is useful depends on the team’s history and review policy.

Command If fast-forward is possible If histories diverged Typical reason
git merge feature-payment Fast-forwards by default Creates a merge commit Let Git choose the simplest safe result
git merge --ff-only feature-payment Fast-forwards Refuses Enforce a linear history
git merge --no-ff feature-payment Creates a merge commit Creates a merge commit Preserve an explicit feature boundary

These are policies applied to the same commit graph; they are not different ways of copying files.

A squash merge takes the combined file effect of the source branch and places it in the target’s staging area:

Terminal window
git switch main
git merge --squash feature-payment
git status
git commit -m "feat: add payment processing"

The final command creates one normal commit on main.

Important consequences:

  • The feature’s many commits become one target-side change.
  • The new commit has one parent; it is not a merge commit.
  • Git does not record the source branch as a merged parent.
  • The original commits still exist on the source branch until that branch is deleted and they become otherwise unreachable.

Squashing can keep the target history compact when a feature branch contains many temporary commits. The trade-off is losing the feature’s individual commit structure from the target history.

A merge conflict can happen during a 3-way or squash merge when Git cannot safely decide how to combine changes. For example, main and feature-payment may have changed the same lines in PaymentService.cs differently.

Git pauses the operation instead of guessing:

Auto-merging PaymentService.cs
CONFLICT (content): Merge conflict in PaymentService.cs
Automatic merge failed; fix conflicts and then commit the result.

This is not repository corruption and it is not automatically a failed team workflow. It means the correct business result requires a human decision.

Git may write markers into the conflicted file:

<<<<<<< HEAD
return await gateway.ChargeAsync(order.Total, cancellationToken);
=======
return await gateway.ChargeAsync(order.Total, order.Currency, cancellationToken);
>>>>>>> feature-payment

In an ordinary merge performed while on main:

  • <<<<<<< HEAD begins ours: the version from the currently checked-out target branch.
  • ======= separates the two versions.
  • >>>>>>> feature-payment ends theirs: the incoming source version.

Do not keep marker lines in the final C# file. Choose the correct version or write a combined implementation, then remove every marker.

First inspect the state:

Terminal window
git status
git diff

Then, for every conflicted file:

  1. Open it and understand both intended behaviors.
  2. Keep one side or combine them into the correct final code.
  3. Delete all <<<<<<<, =======, and >>>>>>> marker lines.
  4. Build and test the result.
  5. Stage the resolved file:
Terminal window
git add PaymentService.cs

After all conflicts are resolved:

Terminal window
git status
git merge --continue

Git may open an editor for the merge commit message. git commit can also finish a normal conflicted merge after all resolutions are staged.

If the chosen integration approach is wrong and you want to return to the pre-merge state:

Terminal window
git merge --abort

This is safest when the working tree was clean before the merge. Unrelated uncommitted changes can make restoration harder, which is another reason to commit or stash first.

Assume the Payment feature is finished and tested:

Terminal window
git switch feature-payment
git status
dotnet test

Now integrate it into main:

Terminal window
git switch main
git status
git merge feature-payment
dotnet test
git log --oneline --graph --decorate --all

If a conflict appears, resolve and stage it, then continue:

Terminal window
git add PaymentService.cs
git merge --continue
dotnet test

Only after verifying the result should the team consider deleting the local feature branch:

Terminal window
git branch -d feature-payment

Deleting the branch is optional cleanup; it is not part of the merge itself.

  • Running git merge main while on feature-payment when the intention was to update main. That merges in the opposite direction.
  • Assuming every merge creates a merge commit. A default fast-forward does not.
  • Assuming every pair of different branch tips must conflict. Different tips can merge automatically when their changes are compatible.
  • Treating a conflict as an instruction to accept one whole side blindly. Often the correct result combines both intentions.
  • Using --squash and assuming Git recorded the source as merged. It did not.
  • Deleting conflict markers without making the code logically correct.
  • Finishing a merge without running the project’s build and tests.
Does the target already contain the source?
├─ Yes → Already up to date
└─ No
├─ Is the target an ancestor of the source?
│ ├─ Yes → Fast-forward by default
│ └─ No → 3-way merge and normally a merge commit
└─ Can Git combine all file changes safely?
├─ Yes → Complete automatically
└─ No → Pause for conflict resolution

Team policy can modify the result with --ff-only, --no-ff, or --squash.

  • Stand on the target branch, then name the source in git merge.
  • Git chooses from the commit graph, not from branch names.
  • A fast-forward moves the target pointer and creates no merge commit.
  • Diverged histories require a 3-way merge, normally producing a two-parent merge commit.
  • --ff-only refuses divergence; --no-ff preserves an explicit merge event.
  • --squash prepares one combined change but does not record a true merge.
  • Conflicts pause integration so a developer can create the correct final code.
  • Start clean, inspect git status, and test the integrated result.
  1. Which branch should be checked out before merging feature-payment into main?
  2. Why can Git fast-forward when main has not moved since the branch point?
  3. What are the three inputs to a 3-way merge?
  4. How does --squash differ from a normal merge commit?
  5. After resolving every conflict, which command marks each file as resolved?