How to Handle Merging, Rebasing & Conflicts in Feature Branches

Published on
Written byChristoffer Artmann
How to Handle Merging, Rebasing & Conflicts in Feature Branches

You've been working on your feature branch for three days. During that time, your team merged eight other pull requests. Now you're ready to merge, but Git is warning you about conflicts. You're not sure whether to merge main into your branch or rebase onto it. The conflict markers in your code look intimidating, and you're worried about making things worse.

This scenario plays out daily in development teams. The mechanics of keeping branches synchronized and resolving conflicts determine whether feature branches feel like a productivity tool or a constant source of anxiety. Understanding these mechanics transforms Git from an obstacle into an ally.

We're going to explore the practical skills you need: when to merge versus rebase, how to keep your feature branch current without pain, strategies for resolving conflicts effectively, and the commands that will save you when things go wrong.

Understanding Why Conflicts Happen

Before diving into resolution strategies, we need to understand what causes conflicts in the first place. Git is remarkably good at automatically merging changes, but it can't read minds. When the same lines of code change in two different ways, Git stops and asks for human judgment.

Consider this example. On Monday, the main branch contains:

function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0)
}

You create a feature branch and modify it to add tax calculation:

function calculateTotal(items) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0)
  return subtotal * 1.08
}

Meanwhile, your teammate merges a change to main that adds discount support:

function calculateTotal(items, discountPercent = 0) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0)
  return subtotal * (1 - discountPercent / 100)
}

Both changes modified the same function. Your change added tax. Their change added discounts. Both are valid, but Git can't automatically combine them because the same lines changed in incompatible ways. This is a conflict.

The key insight is that conflicts aren't bugs or mistakes—they're situations requiring human judgment. Git correctly identified that two people made decisions about the same code, and it needs you to decide how those decisions should combine.

Keeping Your Feature Branch Updated

The longer your feature branch lives without incorporating changes from main, the more conflicts accumulate. Regular synchronization keeps conflict resolution manageable by handling small conflicts frequently rather than large conflicts all at once.

You have two primary strategies for staying current: merging main into your feature branch, or rebasing your feature branch onto main. These approaches create different histories and have different tradeoffs.

Merging Main Into Your Feature Branch

Merging is the safer, more forgiving approach. It preserves all history and creates a clear record of when you integrated changes from main.

# Ensure you have the latest main
git checkout main
git pull origin main

# Switch to your feature branch
git checkout feature/user-authentication

# Merge main into your branch
git merge main

If conflicts occur, Git marks them in your files:

<<<<<<< HEAD
// Your changes in the feature branch
function authenticate(user) {
  return validateCredentials(user.email, user.password)
}
=======
// Changes from main
function authenticate(credentials) {
  return validateUser(credentials.username, credentials.password)
}
>>>>>>> main

The section between <<<<<<< HEAD and ======= shows your feature branch's version. The section between ======= and >>>>>>> main shows main's version. Your job is to combine these into a single, correct version.

After resolving conflicts, you complete the merge:

# Edit files to resolve conflicts
# Then stage the resolved files
git add path/to/resolved/file

# Complete the merge
git commit

Git will open your editor with a pre-populated merge commit message. You can customize it to document any important decisions you made during conflict resolution.

The Advantage of Frequent Merges

Merging main into your feature branch frequently—daily or even multiple times per day—keeps conflicts small and manageable. Instead of facing 50 conflicts after two weeks, you handle three conflicts today, two tomorrow, and four the day after. Each resolution requires less context and less time.

Frequent merging also catches integration issues early. If your feature breaks when combined with recent main changes, you discover this immediately rather than during final integration. The fix is easier because the context is fresh in your mind.

However, frequent merging creates a cluttered history. Your feature branch's log fills with merge commits:

git log --oneline
a1b2c3d Add login validation
e4f5g6h Merge branch 'main' into feature/user-authentication
i7j8k9l Add password hashing
m1n2o3p Merge branch 'main' into feature/user-authentication
q4r5s6t Add session management
u7v8w9x Merge branch 'main' into feature/user-authentication

This clutter makes it harder to understand your feature's development. Code reviewers see merge commits mixed with feature commits, obscuring the actual changes your feature introduces.

Rebasing Your Feature Branch Onto Main

Rebasing offers an alternative. Instead of merging main into your branch, rebasing replays your feature's commits on top of main's current state. This creates a linear history as if you started your feature today rather than days ago.

# Ensure you have the latest main
git checkout main
git pull origin main

# Rebase your feature branch
git checkout feature/user-authentication
git rebase main

Rebasing rewrites history. Your original commits disappear, replaced by new commits with the same changes but different parent commits and different SHA hashes. This rewriting is what creates the clean, linear history, but it's also what makes rebasing more dangerous than merging.

During a rebase, Git applies your commits one at a time. If a commit conflicts, Git pauses:

git rebase main
# Applying: Add login validation
# CONFLICT (content): Merge conflict in auth.js
# error: could not apply a1b2c3d... Add login validation

You resolve the conflict, then continue:

# Edit files to resolve conflicts
git add path/to/resolved/file

# Continue the rebase
git rebase --continue

Git then attempts to apply the next commit. If that also conflicts, you resolve and continue again. This continues until all commits are applied.

When to Choose Rebase Over Merge

Rebasing makes sense in specific situations. If you're preparing a feature branch for final integration and want a clean history for reviewers, rebasing creates a story that's easy to follow. Your commits appear as a logical sequence building on current main, without the noise of merge commits.

If you're working alone on a branch that nobody else has based work on, rebasing is safe. The history rewriting doesn't affect anyone else. You can rebase freely to keep your work current and clean.

If your team values linear history and uses rebasing as standard practice, following that convention maintains consistency. Some open source projects require rebased PRs specifically to keep the main branch history linear.

However, rebasing is dangerous on shared branches. If someone else has based work on your branch, rebasing rewrites the history they're building on. When they try to sync, they'll encounter confusing conflicts and potentially duplicate commits.

The golden rule: never rebase commits that have been pushed to a shared branch unless you're certain nobody else is using them. For shared branches, merging is safer.

Resolving Conflicts Effectively

Conflict resolution is a skill that improves with practice. The key is approaching conflicts systematically rather than randomly editing until Git stops complaining.

Identifying Conflict Markers

When a conflict occurs, Git marks the conflicting sections with special markers:

<<<<<<< HEAD
const result = calculateTotal(items)
=======
const result = computeFinalPrice(items, discount)
>>>>>>> main

The HEAD label refers to your current branch (the feature branch if you're merging main into your branch, or main if you're rebasing). The other label shows where the conflicting changes came from.

Your job is to create code that correctly combines both intentions. This might mean taking one side completely, taking pieces from both, or writing something entirely new that incorporates both changes.

Simple Conflict: Choose One Side

The simplest conflicts occur when you can clearly choose one version:

<<<<<<< HEAD
// Feature branch: Updated to new API
const user = await fetchUserV2(userId)
=======
// Main: Still using old API
const user = await fetchUser(userId)
>>>>>>> main

If you know the feature branch has the correct, updated version, simply remove the conflict markers and keep that version:

const user = await fetchUserV2(userId)

Complex Conflict: Combine Both Changes

Many conflicts require combining elements from both sides:

<<<<<<< HEAD
// Feature branch: Added error handling
try {
  const result = await processPayment(amount)
  return result
} catch (error) {
  console.error('Payment failed:', error)
  throw error
}
=======
// Main: Added logging
const result = await processPayment(amount)
console.log('Payment processed:', result)
return result
>>>>>>> main

The correct resolution incorporates both the error handling and the logging:

try {
  const result = await processPayment(amount)
  console.log('Payment processed:', result)
  return result
} catch (error) {
  console.error('Payment failed:', error)
  throw error
}

Strategic Conflict: Understand Intent

The hardest conflicts require understanding why each change was made:

<<<<<<< HEAD
// Feature branch: Refactored to async/await
async function loadUserData(userId) {
  const profile = await fetchProfile(userId)
  const preferences = await fetchPreferences(userId)
  return { profile, preferences }
}
=======
// Main: Optimized to load in parallel
function loadUserData(userId) {
  return Promise.all([
    fetchProfile(userId),
    fetchPreferences(userId)
  ]).then(([profile, preferences]) => ({ profile, preferences }))
}
>>>>>>> main

Both changes improved the code. Your branch modernized syntax, while main improved performance. The correct resolution combines both improvements:

async function loadUserData(userId) {
  const [profile, preferences] = await Promise.all([
    fetchProfile(userId),
    fetchPreferences(userId)
  ])
  return { profile, preferences }
}

This requires understanding the intent behind both changes, not just mechanically combining text.

Testing After Conflict Resolution

After resolving conflicts, always test. Conflict resolution is error-prone. You might have accidentally removed important code, created syntax errors, or introduced subtle bugs by combining changes incorrectly.

Run your test suite immediately:

# After resolving conflicts
git add resolved-files
npm test  # or whatever runs your tests

# Only commit if tests pass
git commit

If tests fail, you've caught a resolution error early. If you skip testing and commit immediately, broken code enters your branch, potentially confusing your future self or teammates about when the bug was introduced.

Advanced Conflict Resolution Tools

Git provides tools beyond manual editing that can help with complex conflicts.

Using Git Mergetool

Git can launch visual merge tools that provide three-way diffs:

git mergetool

This opens a tool like vimdiff, meld, or kdiff3 (depending on what's installed) showing three panels:

  • Left: your version (from the feature branch)
  • Middle: the common ancestor
  • Right: their version (from main)
  • Bottom: the result you're creating

Seeing the common ancestor helps understand what each side changed. If both sides changed the same line differently, you can see what it originally said and make an informed decision about combining the changes.

Configure your preferred tool globally:

# Use VS Code as merge tool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

# Or use meld
git config --global merge.tool meld

The Ours and Theirs Shortcuts

For conflicts where you want to keep one side completely, Git provides shortcuts:

# Keep your version (feature branch) for specific files
git checkout --ours path/to/file
git add path/to/file

# Keep their version (main) for specific files
git checkout --theirs path/to/file
git add path/to/file

This is useful for files like package-lock.json or dependency files where merging line-by-line doesn't make sense. You might choose to keep main's version of package-lock.json and regenerate it, rather than manually resolving conflicts in a generated file.

Rerere: Reuse Recorded Resolution

Git's rerere (reuse recorded resolution) feature remembers how you resolved conflicts and automatically applies the same resolution if you encounter the same conflict again.

Enable it globally:

git config --global rerere.enabled true

This is particularly valuable when you're frequently rebasing a long-lived feature branch. The first time you resolve a conflict, Git records your resolution. On subsequent rebases, when Git encounters the same conflict, it automatically applies your previous resolution.

You still need to review the automatic resolution and commit, but it saves significant time when dealing with repetitive conflicts.

Strategies for Different Conflict Scenarios

Different situations call for different approaches to keeping branches current and handling conflicts.

Short-Lived Feature Branches

For branches that live less than a week, a simple approach works well:

  1. Work on your feature without worrying about main
  2. When ready to merge, bring main in once
  3. Resolve any conflicts
  4. Create your pull request
# Near the end of feature development
git checkout main
git pull origin main
git checkout feature/quick-fix
git merge main
# Resolve any conflicts
git push origin feature/quick-fix

This minimizes conflict resolution overhead while keeping conflicts manageable since the branch didn't diverge far from main.

Long-Lived Feature Branches

Branches that live for weeks need regular synchronization. Sync with main at least daily, and always before starting new work:

# Start of each work session
git checkout main
git pull origin main
git checkout feature/major-refactor
git merge main  # or git rebase main if you prefer
# Resolve any conflicts
git push origin feature/major-refactor

This keeps conflicts small and ensures you're building on current code. It also surfaces integration issues early when they're easier to fix.

Multiple Developers on One Branch

When multiple developers work on the same feature branch, always use merging, never rebasing. Rebasing rewrites history and will cause nightmares for your collaborators.

Before starting work, pull the latest branch changes:

git checkout feature/shared-work
git pull origin feature/shared-work

Before pushing your work, merge any new commits from the branch:

# You've made commits
git fetch origin
git merge origin/feature/shared-work
# Resolve any conflicts with your teammates' work
git push origin feature/shared-work

This keeps everyone synchronized without the history rewriting that would break their local branches.

Conflicting PRs

Sometimes you'll have multiple PRs open that touch the same code. The first one merges cleanly, but the second now conflicts with main.

For the second PR, bring in the newly updated main:

git checkout feature/second-pr
git merge origin/main
# Resolve conflicts considering what the first PR changed
git push origin feature/second-pr

The key here is understanding what the first PR did so you can resolve conflicts intelligently. Read the first PR's changes, understand why they were made, then resolve conflicts in a way that preserves both features' intentions.

When Things Go Wrong

Git provides safety nets for when conflict resolution goes badly.

Aborting a Merge

If you start a merge and realize it's too complex to handle now, abort it:

git merge --abort

This returns you to the state before you started the merge. All conflict markers disappear, and you're back to your pre-merge code. Use this when you need to think through a resolution strategy or consult with teammates before proceeding.

Aborting a Rebase

Similarly, if a rebase is going poorly:

git rebase --abort

This returns you to your branch's state before the rebase started. All partially applied commits disappear, and your branch is back to normal.

Recovering Lost Work

If you mess up conflict resolution and realize it later, Git's reflog can save you:

# See history of HEAD movements
git reflog

# Find the commit before your bad merge
# Look for the entry right before the merge
reflog: a1b2c3d HEAD@{1}: commit: Last good commit

# Create a new branch from that point
git checkout -b recovery-branch a1b2c3d

The reflog shows every place HEAD has pointed, even commits that aren't reachable from any branch anymore. This lets you recover from almost any mistake as long as the commits were created and you haven't run git gc.

Checking Out Their Version

If you resolved a conflict but later realize you should have just taken main's version, you can reset specific files:

# After the merge commit
git checkout origin/main -- path/to/file
git commit -m "Fix conflict resolution by using main's version"

This replaces the file with main's version while keeping the rest of your merge intact.

Building Conflict Resolution Skills

Conflict resolution improves with practice and good habits.

Make Small, Focused Commits

Small commits are easier to rebase and generate fewer conflicts. Instead of one massive commit with ten changed files, make ten small commits each changing one thing. When conflicts occur, they're isolated to specific, manageable changes.

# Bad: One giant commit
git add .
git commit -m "Implement entire authentication system"

# Good: Multiple focused commits
git add auth/validator.js
git commit -m "Add email validation"

git add auth/hasher.js
git commit -m "Add password hashing"

git add auth/session.js
git commit -m "Add session management"

Communicate with Your Team

Many conflicts arise from lack of communication. If you're refactoring the authentication system and your teammate is adding a feature that uses authentication, you're going to conflict. Talk first, coordinate your work, and avoid simultaneous changes to the same code.

In code review, mention when you've modified heavily-used files. A quick "heads up, I refactored auth.js" in your team chat prevents surprises when others find conflicts in their branches.

Keep Branches Short-Lived

The best way to minimize conflicts is to merge quickly. Branches that live for weeks accumulate technical debt in the form of divergence from main. If you can merge within a day or two, conflicts rarely become unmanageable.

This requires breaking work into smaller pieces—a skill that improves your code design generally. If a feature seems like it will take three weeks, find ways to break it into mergeable increments. Maybe you can merge the data model first, then the API, then the UI, rather than waiting for all three to be done.

Practice Conflict Resolution in Low-Stakes Situations

When you have time, practice resolving conflicts deliberately. Create a branch, make changes, update main, and practice merging. Getting comfortable with the mechanics in a safe environment makes real conflicts less stressful.

You might even create deliberate conflicts:

# Create a practice scenario
git checkout -b practice-main
echo "version 1" > test.txt
git add test.txt
git commit -m "Initial version"

git checkout -b practice-feature
echo "version 2" > test.txt
git commit -am "Feature change"

git checkout practice-main
echo "version 3" > test.txt
git commit -am "Main change"

git checkout practice-feature
git merge practice-main
# Resolve the conflict

This kind of deliberate practice builds confidence and muscle memory for real situations.

The Connection to Better Code Review

Conflict resolution practices directly impact code review quality. When you keep your feature branch current with main, reviewers see your changes against the current codebase, not against a weeks-old version. They can reason about how your changes interact with recent work.

Clean conflict resolution—understanding what both sides of a conflict were trying to achieve and combining them thoughtfully—results in better code than mechanical resolution that just eliminates conflict markers. This care shows in code review and produces more maintainable software.

At Pull Panda, we focus on providing reviewers with the context they need to give meaningful feedback. Part of that context is clean, well-maintained feature branches that tell a clear story. When branches are current and conflicts are thoughtfully resolved, reviews focus on the feature itself rather than getting lost in merge noise.

For more on feature branch workflows that minimize conflicts, check out our complete guide to mastering feature branches. And if you're looking for strategies to prevent branches from becoming stale in the first place, our article on branch cleanup strategies offers practical approaches.