Git merge conflicts feel like a failure the first time they happen, but they are Git declining to guess. Two branches changed the same lines in different ways, and a tool that silently picked one would eventually pick wrong. This part covers the whole path: how branches and merges actually behave, what the conflict markers in your PHP files mean, how to resolve a conflict calmly, and how to back out when you would rather start over. Part 2 covered staging and commits; here we use those commits to build and combine branches.
Commands were run on Git 2.43.0 in a scratch repository. The examples use a small Invoice.php class so the conflicts look like something you would really meet.
A branch is a pointer, so branching is cheap
From Part 1: a branch is a small file holding the hash of one commit. Creating a branch copies nothing, which is why you should make one for every piece of work:
git switch -c feature/tax # create and move to it
# edit, git add -p, git commit...
git switch main
git branch # list local branches; * marks the current one
Commits you make on feature/tax move that branch's pointer forward and leave main exactly where it was.
Two kinds of merge: fast-forward and merge commit
What git merge does depends on the history.
Fast-forward
If main has not moved since you branched, there is nothing to combine. Git just moves the main pointer forward:
git switch main
git merge feature/tax
Updating b58c717..01836ee
Fast-forward
Invoice.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
* 01836ee Add 10% tax to invoice total
* b58c717 Add Invoice
History stays a straight line, and no merge commit is created.
True merge (merge commit)
If both branches have new commits, Git creates a merge commit with two parents. You can also force one even when a fast-forward is possible, which keeps the branch visible in history:
git merge --no-ff feature/note -m "Merge feature/note"
* 4cc93d3 Merge feature/note
|\
| * 2178702 Add note
|/
* 01836ee Add 10% tax to invoice total
* b58c717 Add Invoice
Use git log --oneline --graph to see this shape. If you want to refuse anything that isn't a fast-forward, use git merge --ff-only; it aborts instead of creating a merge commit.
| Situation | Result | Merge commit? |
|---|---|---|
| Target branch hasn't moved | Pointer moves forward | No (fast-forward) |
| Both branches have new commits | Histories are combined | Yes |
Fast-forward possible, --no-ff given |
Merge commit forced | Yes |
Not a fast-forward, --ff-only given |
Git refuses | No |
How a conflict happens
Git compares each branch to their common ancestor. If only one side changed a region, Git takes that change. A conflict appears only when both sides changed the same lines differently. Here, one branch changed the currency to EUR and another, on main, changed it to LKR:
git switch main
git merge feature/eur
Auto-merging Invoice.php
CONFLICT (content): Merge conflict in Invoice.php
Automatic merge failed; fix conflicts and then commit the result.
Nothing is broken. Git has stopped halfway and is waiting for you. git status -s shows UU Invoice.php, meaning unmerged with changes on both sides, and the long form says "You have unmerged paths" and reminds you of the escape hatch (git merge --abort).
Reading the conflict markers
Open the file and you will find this:
public function currency(): string
{
<<<<<<< HEAD
return 'LKR';
=======
return 'EUR';
>>>>>>> feature/eur
}
- Between
<<<<<<< HEADand=======is your side, the branch you are merging into. - Between
=======and>>>>>>> feature/euris the incoming side, the branch you are merging from.
Show the original too with zdiff3
The default view shows only the two results, not what the code looked like before either change. That is often what you need to decide. Switch to a style that includes the common ancestor:
git config --global merge.conflictStyle zdiff3
<<<<<<< HEAD
return 'LKR';
||||||| 4cc93d3
return 'USD';
=======
return 'EUR';
>>>>>>> feature/eur
The middle section, after |||||||, is the base version. Now it is clear that both sides replaced USD, one with LKR and one with EUR, so you have a real decision to make rather than a puzzle. zdiff3 is the newer style and worked on Git 2.43.0 as shown above. If your Git rejects it, use diff3, which shows the same base section with slightly less tidy output.
Resolving a conflict, step by step
-
List what's unresolved.
git diff --name-only --diff-filter=Uprints only the conflicted files. -
Edit each file. Decide the final code, then delete all three marker lines (
<<<<<<<,=======,>>>>>>>) and anything you don't want. The result must be valid code, not a pile of both sides. -
Check for leftover markers.
grep -rn '^<<<<<<<\|^>>>>>>>' .before you go further. A committed marker is a syntax error in PHP. -
Run your tests or at least
php -l. A merge can be textually clean and still logically broken. -
Stage the file.
git add Invoice.phpis how you tell Git "this one is resolved". -
Finish the merge.
git merge --continue(orgit commit) creates the merge commit.
git add Invoice.php
git merge --continue
[main 5f45e2b] Merge branch 'feature/eur'
Note that --continue opens your editor for the merge message, as a normal commit would. It does not accept --no-edit; to skip the editor in a script, use git commit --no-edit instead.
Shortcuts: take one whole side
Sometimes the right answer is simply "use theirs" or "keep mine" for a whole file, such as a generated lockfile. While a conflict is in progress:
git restore --ours Invoice.php # keep the version from the branch you're on
git restore --theirs Invoice.php # take the incoming version
git restore --merge Invoice.php # rebuild the conflict markers if you want to start the file over
git add Invoice.php
I ran each of these: after a second conflict between INR (on main) and GBP (on the other branch), --theirs left return 'GBP'; in the file, --ours left return 'INR';, and --merge put the markers back. These are the git restore equivalents of the older git checkout --ours and --theirs forms.
Be careful with -X. git merge -X theirs feature/eur auto-resolves conflicting hunks in favour of the incoming branch, and non-conflicting changes merge normally. It's convenient, but it makes the decision for you without showing you, so avoid it for code you haven't reviewed. It is not the same as the ours strategy (-s ours), which discards the other branch's content altogether.
Bail out when it goes wrong
git merge --abort
This restores the state from before you started the merge. In my test, git status -s was empty afterwards. If you had uncommitted changes when you began the merge, Git may not be able to restore them cleanly, so commit or stash first (Part 6 covers stash). That is the most important habit: start every merge with a cleangit status.
To see just the commits involved while you are in the middle of a conflict, git log --oneline --merge lists the commits from each side that touch the conflicted files, which is a fast way to find out why two people edited the same line.
Clean up merged branches
Once a branch is merged, delete it. The lowercase flag is the safe one:
git branch -d feature/tax
Deleted branch feature/tax (was 01836ee).
For a branch that is not merged, Git refuses:
error: the branch 'feature/wip' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/wip'
Take that error seriously. -D deletes the branch pointer regardless, and the commits are then only reachable through the reflog for a limited time (Part 5 shows recovery). Use git branch --merged and git branch --no-merged to see which branches are safe to remove.
Avoiding conflicts in the first place
-
Keep branches short-lived. A branch that lives for three weeks diverges from
mainthree weeks' worth. -
Merge
maininto your branch regularly (or rebase, covered in Part 4), so you resolve small conflicts as they appear instead of one large one at the end. - Don't reformat whole files in a feature commit. Whitespace and import-sorting changes create conflicts with everyone else's work.
-
Enable rerere if you resolve the same conflict repeatedly (long-running branches, repeated rebases):
git config --global rerere.enabled truemakes Git remember how you resolved a conflict and reapply that resolution next time. It is off by default, so it only helps once you turn it on.
Common problems
| Symptom | Cause and fix |
|---|---|
git merge --continue rejects --no-edit
|
It doesn't take that option. Use git commit --no-edit, or set GIT_EDITOR=true for the command. |
| PHP parse error right after a merge | Conflict markers were committed. Search for <<<<<<<, fix, and commit again. |
| "You have unmerged paths" | A file is still in conflict. Resolve it and git add it, or run git merge --abort. |
| Merge brought in changes I didn't expect | You merged a stale or wrong branch. Check git log --oneline --graph. If it is not yet pushed, undo it (Part 5). |
git branch -d refuses to delete |
The branch has unmerged commits. Merge it, or use -D only when you're sure you don't need them. |
| Resolved a conflict but Git still shows it | You edited the file but didn't git add it. Staging is what marks it resolved. |
Before you move on
Merge from a clean working tree. Use zdiff3 so you can see the base version. Resolve by choosing final code, not by keeping both sides, and search for leftover markers before staging. Know that git merge --abort is always available until you commit. And delete branches with -d, letting Git tell you when something isn't merged.
References: git-merge, git-branch, git-restore and the merge.conflictStyle entry in git-config in the official Git documentation.
Related reading: Part 1: Git Staging Area Explained and Part 2: Git Commit Best Practices.
Originally published on DEV Talk.
Top comments (1)
tr.ee/dev-to