DEV Community

Cover image for Git Merge Conflicts: Branching, Merging & How to Resolve
devTalk
devTalk

Posted on Originally published at dev-talk.com

Git Merge Conflicts: Branching, Merging & How to Resolve

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Updating b58c717..01836ee
Fast-forward
 Invoice.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Enter fullscreen mode Exit fullscreen mode
* 01836ee Add 10% tax to invoice total
* b58c717 Add Invoice
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
*   4cc93d3 Merge feature/note
|\
| * 2178702 Add note
|/
* 01836ee Add 10% tax to invoice total
* b58c717 Add Invoice
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Auto-merging Invoice.php
CONFLICT (content): Merge conflict in Invoice.php
Automatic merge failed; fix conflicts and then commit the result.
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode
  • Between <<<<<<< HEAD and ======= is your side, the branch you are merging into.
  • Between ======= and >>>>>>> feature/eur is 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
Enter fullscreen mode Exit fullscreen mode
<<<<<<< HEAD
        return 'LKR';
||||||| 4cc93d3
        return 'USD';
=======
        return 'EUR';
>>>>>>> feature/eur
Enter fullscreen mode Exit fullscreen mode

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

  1. List what's unresolved. git diff --name-only --diff-filter=U prints only the conflicted files.
  2. 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.
  3. Check for leftover markers. grep -rn '^<<<<<<<\|^>>>>>>>' . before you go further. A committed marker is a syntax error in PHP.
  4. Run your tests or at leastphp -l. A merge can be textually clean and still logically broken.
  5. Stage the file. git add Invoice.php is how you tell Git "this one is resolved".
  6. Finish the merge. git merge --continue (or git commit) creates the merge commit.
git add Invoice.php
git merge --continue
Enter fullscreen mode Exit fullscreen mode
[main 5f45e2b] Merge branch 'feature/eur'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
Deleted branch feature/tax (was 01836ee).
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 main three weeks' worth.
  • Mergemaininto 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 true makes 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)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to