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:
git switch maingit merge feature-paymentThe order matters:
mainis the target or receiving branch because it is checked out.feature-paymentis the source or incoming branch named aftergit 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.”
Git does not choose by branch name
Section titled “Git does not choose by branch name”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.
Case 1: Already up to date
Section titled “Case 1: Already up to date”If the source commit is already part of the target’s history, the target already contains that work:
A──B──C main └──── feature-paymentRunning this while on main:
git merge feature-paymentproduces a message such as Already up to date. No branch pointer or commit
needs to change.
Case 2: Fast-forward merge
Section titled “Case 2: Fast-forward merge”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-paymentAfter merging feature-payment into main:
A──B──C──D main, feature-paymentGit 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
mainnow match the snapshot atD.
Require a fast-forward with --ff-only
Section titled “Require a fast-forward with --ff-only”Use this when policy requires a linear update and the command must refuse if the histories have diverged:
git switch maingit 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 mainBoth 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:
- The merge base: shared commit
B. - Ours: the current target tip
E(main). - 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 mainCommit 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.
Force a merge commit with --no-ff
Section titled “Force a merge commit with --no-ff”Sometimes a team wants a merge commit even when a fast-forward would be possible, so the feature boundary remains visible:
git switch maingit merge --no-ff feature-paymentThis 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.
Default, --ff-only, and --no-ff
Section titled “Default, --ff-only, and --no-ff”| 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.
Squash merge
Section titled “Squash merge”A squash merge takes the combined file effect of the source branch and places it in the target’s staging area:
git switch maingit merge --squash feature-paymentgit statusgit 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 conflict is a result, not a merge type
Section titled “A conflict is a result, not a merge type”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.csCONFLICT (content): Merge conflict in PaymentService.csAutomatic 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.
Reading conflict markers
Section titled “Reading conflict markers”Git may write markers into the conflicted file:
<<<<<<< HEADreturn await gateway.ChargeAsync(order.Total, cancellationToken);=======return await gateway.ChargeAsync(order.Total, order.Currency, cancellationToken);>>>>>>> feature-paymentIn an ordinary merge performed while on main:
<<<<<<< HEADbegins ours: the version from the currently checked-out target branch.=======separates the two versions.>>>>>>> feature-paymentends 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.
Resolve a merge conflict step by step
Section titled “Resolve a merge conflict step by step”First inspect the state:
git statusgit diffThen, for every conflicted file:
- Open it and understand both intended behaviors.
- Keep one side or combine them into the correct final code.
- Delete all
<<<<<<<,=======, and>>>>>>>marker lines. - Build and test the result.
- Stage the resolved file:
git add PaymentService.csAfter all conflicts are resolved:
git statusgit merge --continueGit may open an editor for the merge commit message. git commit can also
finish a normal conflicted merge after all resolutions are staged.
Abort the merge
Section titled “Abort the merge”If the chosen integration approach is wrong and you want to return to the pre-merge state:
git merge --abortThis 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.
Complete backend example
Section titled “Complete backend example”Assume the Payment feature is finished and tested:
git switch feature-paymentgit statusdotnet testNow integrate it into main:
git switch maingit statusgit merge feature-paymentdotnet testgit log --oneline --graph --decorate --allIf a conflict appears, resolve and stage it, then continue:
git add PaymentService.csgit merge --continuedotnet testOnly after verifying the result should the team consider deleting the local feature branch:
git branch -d feature-paymentDeleting the branch is optional cleanup; it is not part of the merge itself.
Common mistakes
Section titled “Common mistakes”- Running
git merge mainwhile onfeature-paymentwhen the intention was to updatemain. 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
--squashand 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.
Decision guide
Section titled “Decision guide”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 resolutionTeam policy can modify the result with --ff-only, --no-ff, or --squash.
Summary
Section titled “Summary”- 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-onlyrefuses divergence;--no-ffpreserves an explicit merge event.--squashprepares 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.
Knowledge check
Section titled “Knowledge check”- Which branch should be checked out before merging
feature-paymentintomain? - Why can Git fast-forward when
mainhas not moved since the branch point? - What are the three inputs to a 3-way merge?
- How does
--squashdiffer from a normal merge commit? - After resolving every conflict, which command marks each file as resolved?