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.
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
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.
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"
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.
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
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."
GitHub created Pull Request #1.
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
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.
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:
- Create a file on
main. - Create a different version of that file independently on
develop. - Attempt to merge
mainintodevelop.
Creating the First Version on main
I switched to main:
git checkout main
Then created a file called conflict.txt:
echo 'main line' > conflict.txt
git add conflict.txt
git commit -m "main: add line"
The file contained:
main line
Creating a Different Version on develop
Next, I switched to develop:
git checkout develop
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"
This version contained:
develop line
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
Git responded:
CONFLICT (add/add): Merge conflict in conflict.txt
Automatic merge failed; fix conflicts and then commit the result.
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.
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
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
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
I saved the file, then returned to Git Bash.
Telling Git the Conflict Was Resolved
I staged the corrected file:
git add conflict.txt
Then checked the repository:
git status
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"
And just like that, the merge was completed.
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
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"
Documenting the experiment and committing the README.
Next, I pushed the updated develop branch:
git push origin develop
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
Then ran:
git merge --ff-only develop
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
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
Git reported a clean working tree:
nothing to commit, working tree clean
The final history showed main and develop, along with their corresponding remote-tracking references, 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, andgit log --graphhelp 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)