DEV Community

Cover image for I Deliberately Broke a Git Merge to See What Happens Under the Hood
Anderson Chinedum
Anderson Chinedum

Posted on

I Deliberately Broke a Git Merge to See What Happens Under the Hood

I Wanted Git to Fail. And It Did.

I know, that sounds a little crazy. Most developers spend their time trying to avoid errors, and here I was deliberately trying to create one.

But I had a reason.

While experimenting with GitFlow, I became curious about what actually happens when two branches introduce conflicting changes. I've seen merge conflicts before, but I wanted to understand what Git was doing rather than just follow instructions to get rid of the error.

So I decided to put it to the test.

I created conflicting changes on two branches, attempted to merge them, and watched Git respond:

Auto-merging conflict.txt
CONFLICT (add/add): Merge conflict in conflict.txt
Automatic merge failed; fix conflicts and then commit the result.
Enter fullscreen mode Exit fullscreen mode

And there it was. Git refused to complete the merge.

Exactly what I wanted.

But here's where things got interesting. Creating the conflict was relatively easy. Understanding why Git stopped, deciding how to resolve it, and making sure my repository ended up in the right state was a different story.

This experiment took me through GitFlow branching, GitHub pull requests, squash merges, conflict resolution, and finally synchronizing the branches.

Let me walk you through what happened.

Setting Up the Experiment with GitFlow

Before I could deliberately break a merge, I needed a proper branching workflow to experiment with.

I created a repository called gitflow-trunk-strategies and used a GitFlow-style approach with three branches:

  • main — the primary branch representing release-ready work.
  • develop — the integration branch where feature changes come together.
  • feature/user-login — a temporary branch for developing an individual feature.

After initializing the repository and creating the first commit, I set up the development and feature branches:

git checkout -b develop
git checkout -b feature/user-login
Enter fullscreen mode Exit fullscreen mode

Nothing complicated yet.

However, one thing worth remembering is that Git branches aren't separate copies of a project. They're references to commits.

As new commits are introduced, those references move forward, and the branches can develop different histories.

That's what makes branching useful, but it's also what creates interesting situations when you try to combine changes later.

Git Bash terminal showing the creation of the feature/user-login branch alongside the main and develop branches in a GitFlow workflow.

Creating the feature/user-login branch.

Creating a Feature

To make the experiment practical, I created a simple placeholder for a user-login feature:

echo '# Login feature' > login.md

git add login.md
git commit -m "feat: scaffold user login"
Enter fullscreen mode Exit fullscreen mode

This wasn't a complete authentication system. I only needed a real change that could be committed, pushed, and merged.

The feature branch was now ahead of develop by one commit.

Git Bash terminal displaying the successful login feature commit and Git history, with the feature branch pointing to the new commit.

Committing the login feature and inspecting the Git history.

A Quick Note on GitFlow vs. Trunk-Based Development

GitFlow typically uses a long-lived develop branch alongside main, with feature branches created for individual changes.

Trunk-based development, on the other hand, encourages developers to integrate small changes into the mainline frequently.

Both approaches have their uses.

The interesting part is that neither branching strategy magically prevents merge conflicts. Git still has to figure out how to combine changes when branches diverge.

And sometimes, it simply can't make that decision for you.

Getting the Feature into GitHub

With the feature committed locally, I wanted to take it through a proper pull request workflow.

I already had my GitHub repository connected to the local project, with main and develop pushed to the remote.

I used GitHub CLI (gh) to manage the process from my terminal.

First, I pushed the feature branch:

git push -u origin feature/user-login
Enter fullscreen mode Exit fullscreen mode

Then I opened a pull request:

gh pr create \
  --base develop \
  --head feature/user-login \
  --title "feat: user login scaffold" \
  --body "Adds the login module skeleton."
Enter fullscreen mode Exit fullscreen mode

GitHub created Pull Request #1.

GitHub pull request showing the proposed merge of feature/user-login into develop, including the pull request title and branch comparison.

The pull request proposing changes from feature/user-login into develop.

One detail I paid attention to was the target branch.

I used --base develop because I wanted the feature to enter the integration branch first, rather than merge directly into main.

Why I Used a Squash Merge

After checking the pull request, I completed it using:

gh pr merge feature/user-login --squash --delete-branch
Enter fullscreen mode Exit fullscreen mode

A squash merge takes the changes introduced by the feature branch and records them as a new commit on the target branch.

For example, if a feature branch contains several intermediate commits for fixes and adjustments, squashing can combine those changes into one integration commit.

My feature only had one commit, but the exercise still demonstrated the workflow.

The pull request was merged successfully, and the temporary feature branch was deleted.

Git Bash terminal confirming a successful squash merge of the login feature, deletion of the feature branch, and synchronization of develop.

The feature successfully squash-merged into develop.

At this point, the feature existed on develop.

But main was still behind.

That wasn't an error. It was simply a consequence of the branching workflow. Merging into develop doesn't automatically update every other branch.

This distinction would become useful later in the experiment.

For now, though, I had something else in mind.

It was time to break a merge.

Time to Break Something: Creating the Merge Conflict

Here's where I stopped following the normal feature workflow and started experimenting.

I wanted Git to encounter two different versions of the same file and refuse to merge them automatically.

The plan was simple:

  1. Create a file on main.
  2. Create a different version of that file independently on develop.
  3. Attempt to merge main into develop.

Creating the First Version on main

I switched to main:

git checkout main
Enter fullscreen mode Exit fullscreen mode

Then created a file called conflict.txt:

echo 'main line' > conflict.txt

git add conflict.txt
git commit -m "main: add line"
Enter fullscreen mode Exit fullscreen mode

The file contained:

main line
Enter fullscreen mode Exit fullscreen mode

Creating a Different Version on develop

Next, I switched to develop:

git checkout develop
Enter fullscreen mode Exit fullscreen mode

I created another file with the exact same name, but different content:

echo 'develop line' > conflict.txt

git add conflict.txt
git commit -m "develop: add line"
Enter fullscreen mode Exit fullscreen mode

This version contained:

develop line
Enter fullscreen mode Exit fullscreen mode

So now I had two branches that had independently added conflict.txt.

Same filename. Different content.

And both changes were already committed.

Attempting the Merge

While on develop, I ran:

git merge main
Enter fullscreen mode Exit fullscreen mode

Git responded:

CONFLICT (add/add): Merge conflict in conflict.txt
Automatic merge failed; fix conflicts and then commit the result.
Enter fullscreen mode Exit fullscreen mode

There it was again.

The merge stopped because Git couldn't automatically reconcile the two different versions of the file.

This particular situation is called an add/add conflict because both branches independently added the same file path.

And no, I hadn't damaged my repository.

Git was actually protecting the merge from proceeding with an ambiguous result.

VS Code's Git Bash terminal showing an add/add merge conflict after running git merge main on the develop branch. Git reports a conflict in conflict.txt, stops the automatic merge, and git status confirms that the repository is in a merging state with unresolved paths requiring manual resolution.

Git detecting an add/add merge conflict in conflict.txt.

Now the question became: what exactly was Git struggling to reconcile?

What Was Actually Happening Under the Hood?

This was the part I was particularly interested in.

Git doesn't simply compare two files and pick the one with the latest timestamp.

For a typical three-way merge, Git considers three versions of the project:

  • The merge base: The common ancestor shared by the two branches.
  • Ours: The version from the current branch, develop.
  • Theirs: The version coming from main.

The common ancestor helps Git determine how each branch changed.

In my case, conflict.txt didn't exist in that common ancestor.

Both branches independently introduced the file, and their contents were different.

That's why Git reported an add/add conflict.

Understanding the Conflict Markers

When I inspected conflict.txt, Git had inserted markers similar to these:

<<<<<<< HEAD
develop line
=======
main line
>>>>>>> main
Enter fullscreen mode Exit fullscreen mode

Let's break that down.

<<<<<<< HEAD

This marks the beginning of the current branch's version. Since I was on develop, that was the content Git considered "ours."

=======

This separates the conflicting versions.

>>>>>>> main

This marks the end of the incoming version from main.

Git was essentially saying:

"I found two competing versions of this file. I can show you both, but I need you to decide what the final result should be."

And that's exactly what merge conflict resolution is about.

There's More Going On Than Just Conflict Markers

Git also tracks unresolved versions through its index.

During an unresolved merge, the index can contain separate entries for the conflicting sides.

For an add/add conflict like mine, the relevant entries are typically:

  • Stage 2: The version from the current branch.
  • Stage 3: The version from the incoming branch.

There is no stage-1 version for that file because it didn't exist in the merge base.

One way to inspect those entries while a conflict is still unresolved is:

git ls-files -u
Enter fullscreen mode Exit fullscreen mode

I didn't use this command during the original lab, but it's a useful way to explore the underlying conflict state in a future experiment.

The conflict markers were simply the visible part of a more structured process.

Git had the competing versions available. What it didn't have was enough information to choose the correct result automatically.

That's where I had to step in.

Fixing the Merge Without Losing the Changes

At first, I thought resolving the conflict meant removing the markers and keeping both versions.

But I wanted to understand whether that was actually how conflicts should be handled.

Turns out, there's an important distinction.

Removing conflict markers doesn't automatically mean you've resolved the underlying problem correctly.

Imagine two developers changing the same configuration setting.

One version might specify a database connection to one environment, while another points somewhere else.

Keeping both settings could introduce invalid configuration or unexpected behavior.

So the correct resolution depends on what the code is supposed to do.

In my experiment, the file contained nothing more than two demonstration lines. Both could safely coexist.

I edited conflict.txt and kept:

develop line
main line
Enter fullscreen mode Exit fullscreen mode

I saved the file, then returned to Git Bash.

Telling Git the Conflict Was Resolved

I staged the corrected file:

git add conflict.txt
Enter fullscreen mode Exit fullscreen mode

Then checked the repository:

git status
Enter fullscreen mode Exit fullscreen mode

At this point, Git could recognize that the conflict had been resolved, but the merge itself was still waiting to be completed.

This was another useful lesson.

git add marks the resolved content as ready. It doesn't finish the merge commit.

To complete the process, I ran:

git commit -m "merge: resolve conflict between main and develop"
Enter fullscreen mode Exit fullscreen mode

And just like that, the merge was completed.

Git Bash terminal showing the completed merge-conflict resolution commit and Git history after combining changes from main and develop.

The successful merge commit after resolving the conflicting file.

What Changed in the Git History?

Unlike the earlier squash merge, this operation produced a regular merge commit.

A normal commit usually has one parent, while a merge commit typically has two.

Those two parent references allow Git to record how the previously separate histories were connected.

I could inspect that relationship using:

git log --oneline --graph --decorate --all
Enter fullscreen mode Exit fullscreen mode

This is one reason I find Git's history commands so useful.

A command reporting success is good.

Being able to inspect the resulting commit history is even better.

The conflict was resolved. But I still had one final task before calling the experiment complete.

The Merge Was Fixed. But Were My Branches Actually in Sync?

After resolving the conflict, I moved on to documenting the lab and publishing the final changes.

I created a README.md covering the branching strategies, pull request workflow, and important commands used during the experiment.

Then I committed it:

git add .

git commit -m "docs: add README for GitFlow vs Trunk branching lab"
Enter fullscreen mode Exit fullscreen mode

Git Bash terminal confirming the README documentation commit, recording the GitFlow and trunk-based development lab workflow.

Documenting the experiment and committing the README.

Next, I pushed the updated develop branch:

git push origin develop
Enter fullscreen mode Exit fullscreen mode

But there was still something to address.

Remember that the login feature had been integrated into develop, while main had remained behind?

Even after resolving the conflict, the branches weren't automatically pointing to the same latest commit.

I needed to synchronize them.

Using a Fast-Forward Merge

I switched to main:

git checkout main
Enter fullscreen mode Exit fullscreen mode

Then ran:

git merge --ff-only develop
Enter fullscreen mode Exit fullscreen mode

The --ff-only option tells Git to merge only if it can advance the current branch without creating a new merge commit.

This works when the current branch's commit is already an ancestor of the target branch.

Because I had merged main into develop during the conflict-resolution exercise, Git could now fast-forward main to the latest commit on develop.

No additional merge commit was necessary.

Finally, I pushed main:

git push origin main
Enter fullscreen mode Exit fullscreen mode

Verifying the Final State

I didn't want to stop at a successful push message.

I wanted to confirm that everything was actually where it should be.

So I ran:

git status
git branch -vv
git log --oneline --graph --decorate --all -12
Enter fullscreen mode Exit fullscreen mode

Git reported a clean working tree:

nothing to commit, working tree clean
Enter fullscreen mode Exit fullscreen mode

The final history showed main and develop, along with their corresponding remote-tracking references, pointing to the same latest commit.

Git Bash terminal showing successful repository synchronization, a clean working tree, and main and develop pointing to the same latest commit.

The final Git history showing the synchronized branches.

That was the result I was looking for.

The conflict was gone, the changes were committed, the documentation was pushed, and the repository history was in the expected state.

Now I could actually say the experiment was finished.

Three Merge Strategies, Three Different Results

One thing this experiment helped me understand is that not every merge operation produces the same Git history.

I used three different integration approaches throughout the workflow.

Merge Strategy What Happens Why It Matters
Squash Merge Combines the feature's changes into a new commit on the target branch Keeps the integration history compact
Regular Merge Commit Connects the histories of two branches with a merge commit Preserves the relationship between the branches
Fast-Forward Merge Advances a branch reference without creating a new merge commit Useful when the current branch is an ancestor of the target

What's interesting is that all three approaches can successfully integrate changes, but the resulting history isn't identical.

With the feature branch, I used a squash merge.

When resolving the intentional conflict, I created a regular merge commit.

And when synchronizing main with develop, I used a fast-forward merge.

Seeing these operations in the same repository made the differences much easier to understand.

It also reinforced something important: the merge strategy you choose affects how future developers will read and understand the project's history.

That might not seem significant in a tiny lab repository.

But in a larger project with multiple contributors, meaningful commit history becomes much more valuable.

Final Thoughts: Breaking Things Can Teach You How They Work

I started this experiment with one goal: make Git refuse a merge.

And I succeeded.

But what I found interesting wasn't the error itself. It was everything I had to understand to get from that error back to a clean, synchronized repository.

A few lessons stood out to me:

  • A merge conflict doesn't mean your repository is broken. It means Git needs a decision it cannot make automatically.
  • Removing conflict markers isn't enough. You still need to make sure the resolved code behaves correctly.
  • A successful pull request doesn't mean every branch is up to date. You need to understand which branch received the changes.
  • Git history deserves attention. Commands like git status, git branch -vv, and git log --graph help verify the actual result.

And perhaps the biggest takeaway?

Getting a merge to succeed isn't the goal. Understanding what was merged, why it was merged, and whether the resulting history is correct matters much more.

This was a controlled experiment using simple files, not a complicated production application. But the underlying Git behavior is the same kind of behavior developers need to understand when collaborating on real projects.

I'll be taking that lesson into my future software engineering and DevOps work.

Explore the Repository

If you'd like to inspect the commits and branching workflow yourself, the completed repository is available on GitHub:

GitFlow vs. Trunk-Based Development Lab

I'm curious: have you ever resolved a Git merge conflict, only to discover that the resulting code or branch history wasn't what you expected?

I'd be interested to hear how you handled it.

Top comments (0)