Merging, Rebasing, and Conflict Resolution

Module 2: Version Control & Containerization

Introduction

In our previous lecture, we explored branching strategies and workflows in Git. We discussed how branches allow developers to work independently without affecting the main codebase. But what happens when it's time to combine these separate lines of development?

This is where merging and rebasing come in—two different approaches to integrating changes from one branch into another. While they achieve similar goals, they do so in different ways, each with its own advantages and considerations.

Just as important is understanding how to handle conflicts—those inevitable situations where Git needs human intervention to resolve competing changes. Successfully navigating merge conflicts is a critical skill for any developer using Git.

In this lecture, we'll dive deep into:

By the end of this lecture, you'll understand how to seamlessly integrate changes across branches and confidently handle any conflicts that arise.

Merging Fundamentals

Merging is the process of integrating changes from one branch into another. It's like combining two separate streams of work into a unified whole.

Types of Merges

Git performs different types of merges depending on the relationship between the branches:

Fast-Forward Merge

When the target branch is a direct ancestor of the source branch (i.e., no divergent work on the target branch), Git performs a "fast-forward" merge by simply moving the pointer of the target branch forward.

gitGraph commit id: "C1" commit id: "C2" branch feature checkout feature commit id: "C3" commit id: "C4" checkout main merge feature

In this scenario, main was not changed since feature was created, so merging feature into main just moves the main pointer forward to include C3 and C4.

Command:

git checkout main
git merge feature

Three-Way Merge

When both branches have diverged (i.e., each has unique commits), Git performs a "three-way" merge, creating a new merge commit that represents the integration of the two branches.

gitGraph commit id: "C1" commit id: "C2" branch feature checkout feature commit id: "C3" checkout main commit id: "C4" checkout main merge feature id: "C5"

Here, both main and feature have new commits since they diverged. The merge creates a new commit (C5) with two parents.

Command:

git checkout main
git merge feature

This creates a merge commit with a default commit message, which you can customize in the editor that opens.

Squash Merge

A squash merge takes all the changes from the source branch and condenses them into a single commit on the target branch.

gitGraph commit id: "C1" commit id: "C2" branch feature checkout feature commit id: "C3" commit id: "C4" checkout main merge feature id: "C5" type: HIGHLIGHT

The resulting commit C5 contains all the changes from C3 and C4, but as a single new commit on main. The feature branch history remains unchanged, but its changes are incorporated as one commit on main.

Command:

git checkout main
git merge --squash feature
git commit -m "Merge feature branch"

Note that --squash doesn't automatically create a commit, so you need to run a separate commit command.

Merge Commit Messages

When performing a merge that creates a new commit, Git generates a default message like "Merge branch 'feature' into main". You can customize this message either by:

A good merge commit message should:

Merge Strategies and Options

Git offers several merge strategies and options for different scenarios:

Merge Strategies

Useful Merge Options

Example with options:

# Create a merge commit even if fast-forward is possible
git merge feature --no-ff

# Use the "ours" strategy option for the recursive merge strategy
git merge feature -X ours

Rebasing Fundamentals

While merging integrates branches by creating a merge commit, rebasing takes a different approach: it rewrites history by moving the entire branch to a new base commit.

How Rebasing Works

Rebasing is best understood as "replaying" commits from one branch onto another. When you rebase, Git:

  1. Finds the common ancestor of the two branches
  2. Stores the changes introduced by the commits in your current branch as temporary files ("patches")
  3. Resets your current branch to the same commit as the branch you're rebasing onto
  4. Applies each patch one by one, creating new commits

This effectively moves your branch to have a new starting point, as if you had created it from the latest commit on the target branch.

gitGraph commit id: "C1" commit id: "C2" branch feature checkout feature commit id: "C3" commit id: "C4" checkout main commit id: "C5" commit id: "C6"

After rebasing feature onto main:

gitGraph commit id: "C1" commit id: "C2" commit id: "C5" commit id: "C6" branch feature commit id: "C3'" commit id: "C4'"

Notice that the commits C3 and C4 are recreated as C3' and C4' on top of main's latest commit.

Basic rebase command:

git checkout feature
git rebase main

When to Use Rebasing

Rebasing is particularly useful in these scenarios:

Updating a Feature Branch

Keeping a long-running feature branch up to date with the latest main branch changes:

git checkout feature
git rebase main

This keeps your feature branch current without creating merge commits.

Cleaning Up Before Sharing

Cleaning up your local history before pushing to a shared repository:

git checkout feature
git rebase -i HEAD~5  # Interactively rebase the last 5 commits

This allows you to squash, reword, or otherwise edit your commits before sharing them.

Creating Linear History

Some teams prefer a linear commit history without merge commits. After rebasing a feature branch:

git checkout main
git merge feature

This will result in a fast-forward merge since feature is now ahead of main.

The Golden Rule of Rebasing

Never rebase commits that exist outside your repository (i.e., that you've already pushed and others might have based work on).

Why? Because rebasing creates new commits with the same changes but different hashes. If others have based work on your original commits, rebasing will cause major confusion when you try to integrate those histories later.

Think of it like changing the foundation of a building after others have already built on top of it—it can cause the entire structure to collapse.

Breaking this rule can lead to painful situations where the same changes appear multiple times in the history and conflicts become incredibly difficult to resolve.

Interactive Rebasing

One of the most powerful features of Git is interactive rebasing, which gives you control over each commit in the rebasing process:

git rebase -i <base-commit>

This opens an editor with a list of commits and instructions for how to handle each one:

pick 36d1535 Add login form
pick 7bc563a Style login form
pick 08e4e17 Add form validation
pick 2bc4f3c Fix validation bug
pick 0bec652 Update login documentation

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's message
# d, drop = remove commit
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#

By changing the word "pick" to one of the other commands, you can transform your commit history in various ways:

Common Interactive Rebase Operations

Interactive rebasing is like having a time machine for your commits—you can revise history to make it cleaner and more logical before sharing it with others.

Merge vs. Rebase: Choosing the Right Approach

Both merging and rebasing integrate changes from one branch into another, but they do so in fundamentally different ways. Let's compare them to understand when to use each approach.

Conceptual Differences

flowchart TD A[Integrating Branch B into Branch A] --> B{Choose Method} B -->|Merge| C[Create a new commit
that combines changes] B -->|Rebase| D[Replay Branch A's
commits on top of Branch B] C --> E[Preserves complete history
Shows when branches
diverged and merged] D --> F[Creates linear history
As if Branch A was
created from latest Branch B] style A fill:#f9f9f9,stroke:#333,stroke-width:2px style B fill:#f9f9f9,stroke:#333 style C fill:#e1f5fe,stroke:#0288d1 style D fill:#fff3e0,stroke:#ff9800 style E fill:#e1f5fe,stroke:#0288d1 style F fill:#fff3e0,stroke:#ff9800

Comparing Characteristics

Characteristic Merge Rebase
History Structure Preserves branch history with merge commits Creates linear history by recreating commits
Commit Integrity Preserves original commits exactly as they were created Creates new commits with the same changes but different hashes
Traceability Clearly shows when branches diverged and were integrated Makes it appear as if work was done sequentially
Conflict Resolution Resolve conflicts once during the merge May need to resolve conflicts for each commit being rebased
Safety Safe to use on public branches Should only be used on private/local branches
Visibility of Integration Explicit merge commits show where integration happened No explicit indication of when branches were integrated

When to Merge

Merging is typically better when:

When to Rebase

Rebasing is typically better when:

Hybrid Approaches

Many teams use hybrid approaches that combine the benefits of both methods:

Rebase Then Merge

  1. Rebase your feature branch onto the latest main
  2. Create a merge commit when integrating into main
git checkout feature
git rebase main
git checkout main
git merge feature --no-ff

This results in a linear history within the feature branch, but preserves the record of the feature integration.

Squash and Merge

  1. Clean up your feature branch with interactive rebasing
  2. Squash all feature commits into a single commit when merging
git checkout feature
git rebase -i main
git checkout main
git merge --squash feature
git commit -m "Add feature X"

This combines the detailed history during development with a clean, focused integration into the main branch.

Understanding Merge Conflicts

Merge conflicts occur when Git cannot automatically reconcile differences between branches. This happens when the same part of a file has been modified in different ways on the branches being integrated.

What Causes Conflicts?

Conflicts typically arise from:

Anatomy of a Conflict

When Git encounters a conflict, it modifies the affected file to show both versions of the conflicting section:

<<<<<<< HEAD
This is the content from the branch you're merging into (current branch)
=======
This is the content from the branch you're merging from (incoming branch)
>>>>>>> feature-branch

The conflict markers divide the file into sections:

Git also updates the staging area to reflect the conflict state:

You can see this status by running git status during a conflict:

$ git status
On branch main
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
        both modified:   README.md

Resolving Merge Conflicts

When you encounter a merge conflict, Git pauses the merge process and asks you to resolve the conflicts manually. Here's a step-by-step approach to effective conflict resolution:

Basic Conflict Resolution Process

  1. Identify conflicting files: Use git status to see which files have conflicts
  2. Open each conflicting file: Use your editor to view and edit the conflict markers
  3. Choose how to resolve each conflict: You can keep one version, combine both, or write something entirely new
  4. Remove conflict markers: Delete the <<<<<<<, =======, and >>>>>>> lines
  5. Mark as resolved: Use git add <file> to mark each file as resolved
  6. Complete the merge: Once all conflicts are resolved, run git commit to create the merge commit

Resolution Strategies

When deciding how to resolve a conflict, consider these strategies:

Accept One Version

If one version is clearly correct, you can choose to keep it entirely:

Keep your changes (current branch):

<<<<<<< HEAD
This is the content to keep
=======
This is the content to discard
>>>>>>> feature-branch

Replace with:

This is the content to keep

Keep their changes (incoming branch):

<<<<<<< HEAD
This is the content to discard
=======
This is the content to keep
>>>>>>> feature-branch

Replace with:

This is the content to keep

Combine Both Changes

Often, you'll want to incorporate elements from both versions:

<<<<<<< HEAD
Users can log in with email
=======
Users can log in with username
>>>>>>> feature-branch

Replace with:

Users can log in with email or username

Create Something New

Sometimes, neither version is quite right, and you need to implement a third solution:

<<<<<<< HEAD
function calculateTotal(items) {
  return items.reduce((total, item) => total + item.price, 0);
}
=======
function calculateTotal(items) {
  let total = 0;
  for (const item of items) {
    total += item.price;
  }
  return total;
}
>>>>>>> feature-branch

Replace with a better implementation:

function calculateTotal(items) {
  if (!items || items.length === 0) return 0;
  return items.reduce((total, item) => total + (item.price || 0), 0);
}

Using Git Tools for Conflict Resolution

Git provides several commands to help with conflict resolution:

Aborting a Problematic Merge

If you want to cancel a merge and start over:

git merge --abort

This command reverts the working directory and index to their state before the merge began.

Using Built-in Merge Tool

Git has a built-in tool to help resolve conflicts:

git mergetool

This launches a visual diff and merge tool. Git supports various tools like vimdiff, kdiff3, meld, and many others.

Checking Differences Between Branches

To understand the context of changes before resolving conflicts:

git diff --ours   # Shows differences between common ancestor and HEAD
git diff --theirs # Shows differences between common ancestor and the branch being merged

Choosing One Side Automatically

For some files, you might want to automatically choose one version:

git checkout --ours path/to/file   # Use the version from your current branch
git checkout --theirs path/to/file # Use the version from the incoming branch
git add path/to/file               # Mark as resolved

Showing Conflict Markers Again

If you accidentally removed conflict markers but want them back:

git checkout -m path/to/file

This restores the file to its conflicted state with markers.

Visual Merge Tools

Many developers prefer visual tools for resolving conflicts. These tools present the conflicting sections side by side, making it easier to see the differences and choose the correct resolution.

Popular merge tools include:

To configure Git to use your preferred tool:

git config --global merge.tool <tool>
git config --global mergetool.<tool>.path /path/to/tool # if needed

For example, to use VS Code:

git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'

Advanced Conflict Resolution Techniques

For complex projects or particularly challenging conflicts, you may need more advanced techniques to effectively resolve conflicts and maintain a clean history.

Understanding Conflict Resolution in Rebasing

Rebasing can involve multiple conflict resolution steps, as Git applies each commit one by one:

  1. Git starts the rebase by trying to apply the first commit
  2. If a conflict occurs, Git pauses and asks you to resolve it
  3. After resolution and git add, you continue with git rebase --continue
  4. Git tries to apply the next commit, potentially leading to another conflict
  5. This process repeats until all commits are applied

At any point, you can abort the rebase with git rebase --abort.

For complex rebases, it may be easier to:

  1. Perform a merge to resolve all conflicts at once
  2. Reset to the state before the merge
  3. Then perform the rebase, which will now have the conflict resolution "built in"
# Approach for handling complex rebase conflicts
git checkout feature
git merge main           # Resolve all conflicts at once
git reset --soft HEAD~1  # Undo the merge but keep the resolutions
git stash                # Save the conflict resolutions
git rebase main          # Start the rebase
git stash apply          # Apply the saved resolutions
# Resolve any remaining conflicts
git rebase --continue

Cherry-Picking and Conflict Resolution

Cherry-picking applies specific commits from one branch to another and can also lead to conflicts:

git cherry-pick <commit-hash>

If conflicts occur during cherry-picking:

  1. Resolve conflicts in the files
  2. Add the resolved files with git add
  3. Continue with git cherry-pick --continue
  4. Or abort with git cherry-pick --abort

Cherry-picking can be especially useful for applying specific bug fixes across multiple release branches.

Resolving Binary File Conflicts

Binary files (images, PDFs, etc.) cannot be merged line-by-line like text files. When binary files conflict, you have three options:

  1. Choose one version:
    git checkout --ours path/to/binary.file
    # or
    git checkout --theirs path/to/binary.file
    git add path/to/binary.file
  2. Use an external merge tool that supports the binary format
  3. Manually create a new version and replace the conflicted file

For projects with many binary files, consider using Git LFS (Large File Storage) to better handle these files.

Using .gitattributes for Custom Merge Strategies

You can define custom merge strategies for specific files using the .gitattributes file:

# .gitattributes
database.xml merge=ours  # Always use our version during merge conflicts
*.generated.cs -merge    # Treat as binary (don't try to merge)
*.css merge=union        # Combine both versions for CSS files

Common merge strategies in .gitattributes:

Merge Conflict Prevention

The best way to handle conflicts is to prevent them in the first place:

Real-World Merge and Rebase Scenarios

Let's explore some common real-world scenarios and how to handle them effectively using merge and rebase techniques.

Scenario 1: Feature Branch Getting Out of Date

Situation: You've been working on a feature branch for a week, and the main branch has moved forward with other developers' changes.

Solution: Update your feature branch by rebasing on main.

gitGraph commit id: "A" commit id: "B" branch feature checkout feature commit id: "F1" commit id: "F2" checkout main commit id: "C" commit id: "D"

After rebase:

gitGraph commit id: "A" commit id: "B" commit id: "C" commit id: "D" branch feature checkout feature commit id: "F1'" commit id: "F2'"
git checkout feature
git rebase main
# Resolve any conflicts that arise
# Continue development on an up-to-date feature branch

Benefit: Your feature is now based on the latest main, making eventual integration smoother.

Scenario 2: Cleaning Up a Feature Branch Before Merging

Situation: Your feature branch has many small, incremental commits with messages like "WIP" or "Fix typo", and you want to clean it up before merging.

Solution: Use interactive rebasing to reorganize and squash commits.

gitGraph commit id: "A" branch feature checkout feature commit id: "WIP" commit id: "Fix typo" commit id: "More work" commit id: "Bug fix" commit id: "Final touches"

After interactive rebase:

gitGraph commit id: "A" branch feature checkout feature commit id: "Implement feature X with tests"
git checkout feature
git rebase -i main

# In the editor, change:
pick abcd123 WIP
pick efgh456 Fix typo
pick ijkl789 More work
pick mnop012 Bug fix
pick qrst345 Final touches

# To:
pick abcd123 WIP
squash efgh456 Fix typo
squash ijkl789 More work
squash mnop012 Bug fix
squash qrst345 Final touches

# In the next editor, write a comprehensive commit message:
Implement feature X with tests

This commit:
- Adds the core feature X functionality
- Includes comprehensive tests
- Handles edge cases
- Updates documentation

Benefit: The feature is represented by a single, well-documented commit that's easier to review and understand.

Scenario 3: Implementing a Feature with Dependent Sub-Features

Situation: You're working on a large feature that has several sub-features, and you want each sub-feature to be reviewed separately.

Solution: Use multiple branches with a clear dependency chain.

gitGraph commit id: "A" branch feature-base checkout feature-base commit id: "Base infrastructure" branch feature-auth checkout feature-auth commit id: "Authentication" branch feature-ui checkout feature-ui commit id: "UI components" checkout main merge feature-base merge feature-auth merge feature-ui
# Start with the base feature
git checkout -b feature-base main
# Implement base infrastructure and create PR

# Once feature-base is merged or at least reviewed
git checkout -b feature-auth main
git merge feature-base  # or rebase onto feature-base if not yet merged
# Implement authentication and create PR

# Finally, add the UI components
git checkout -b feature-ui main
git merge feature-auth  # or rebase onto feature-auth if not yet merged
# Implement UI and create PR

Benefit: Each part can be reviewed separately, and dependencies are clear. If one part needs changes, it doesn't block the entire feature.

Scenario 4: Handling Release Branches and Hotfixes

Situation: You've created a release branch, but a critical bug is discovered that needs to be fixed in both the release and main branches.

Solution: Create a hotfix branch from the release branch and cherry-pick the fix to main.

gitGraph commit id: "A" commit id: "B" branch release/1.0 checkout release/1.0 commit id: "Release prep" branch hotfix/bug checkout hotfix/bug commit id: "Fix critical bug" checkout release/1.0 merge hotfix/bug tag: "v1.0.1" checkout main commit id: "C" cherry-pick id: "Fix critical bug"
git checkout release/1.0
git checkout -b hotfix/bug

# Fix the bug and commit
git add .
git commit -m "Fix critical bug"

# Update the release branch
git checkout release/1.0
git merge hotfix/bug
git tag -a v1.0.1 -m "Version 1.0.1"

# Also apply the fix to main
git checkout main
git cherry-pick hotfix/bug  # Use the commit hash of the fix
# Resolve any conflicts if needed

Benefit: The bug is fixed in both the current release and future releases, while maintaining a clear history of when and why the fix was applied.

Scenario 5: Resolving Complex Merge Conflicts with Multiple Developers

Situation: A large feature branch has significant conflicts with main when it's time to merge, involving files worked on by multiple developers.

Solution: Hold a merge party with all involved developers.

# Preparation
git checkout feature
git rebase main  # Try to rebase first to see the conflicts

# If rebasing is too complex, abort and try merging
git rebase --abort
git merge main  # This will show conflicts

# During the merge party
# 1. Go through each conflicted file
# 2. Have the relevant developers explain their changes
# 3. Decide on the best resolution together
# 4. Mark each file as resolved
git add path/to/resolved/file

# Once all conflicts are resolved
git commit  # Complete the merge with a detailed message

Benefit: Collaborative resolution ensures the best outcome with input from all stakeholders, reducing the risk of introducing bugs or losing important changes.

Best Practices for Merging and Rebasing

To make your merge and rebase operations as smooth as possible, follow these best practices:

General Best Practices

Merging Best Practices

Rebasing Best Practices

Conflict Resolution Best Practices

Team Workflow Considerations

Practice Exercises

Apply what you've learned with these hands-on exercises:

Exercise 1: Basic Merge and Conflict Resolution

  1. Create a new Git repository with a file called README.md containing some text
  2. Create two branches: feature1 and feature2
  3. In feature1, modify the README.md file by adding a section about installation
  4. In feature2, modify the same README.md file by adding a section about usage
  5. Merge feature1 into main
  6. Try to merge feature2 into main - you should encounter a conflict
  7. Resolve the conflict to include both the installation and usage sections
  8. Complete the merge and view the history with git log --graph --oneline

Exercise 2: Rebasing and Interactive Rebasing

  1. Create a new Git repository with a file called app.js
  2. Make several commits to the main branch
  3. Create a feature branch and make several small commits
  4. Return to main and make another commit
  5. Switch back to your feature branch and use rebase to incorporate the new main branch commit
  6. Use interactive rebasing to clean up your feature branch:
    • Squash some related commits
    • Reword a commit message
    • Reorder commits if needed
  7. Verify the modified history with git log

Exercise 3: Merge Strategies Comparison

  1. Create a new Git repository with several files
  2. Create three identical feature branches: feature-regular, feature-noff, and feature-squash
  3. Make the same series of commits on each branch
  4. Merge each branch into main using different strategies:
    • Regular merge (fast-forward if possible): git merge feature-regular
    • No-fast-forward merge: git merge --no-ff feature-noff
    • Squash merge: git merge --squash feature-squash followed by git commit
  5. Compare the resulting history with git log --graph --oneline
  6. Consider the pros and cons of each approach

Exercise 4: Cherry-Picking and Selective Integration

  1. Create a new Git repository with a file called project.js
  2. Create a branch called experimental and make several commits, each adding a different function to the file
  3. Decide that only some of these functions are ready for production
  4. Cherry-pick only the commits with the ready functions into the main branch
  5. Verify that main now contains only the selected functions

Exercise 5: Complex Merge Conflict Resolution

  1. Create a new Git repository with a file containing a complex data structure (like a JSON configuration)
  2. Create two branches and make significant changes to different but overlapping parts of the structure in each branch
  3. Attempt to merge one branch into the other, which should result in conflicts
  4. Use a visual merge tool to resolve the conflicts (try git mergetool if you have one configured)
  5. Create a resolution that preserves the important changes from both branches
  6. Complete the merge and test that the resulting file is valid (e.g., valid JSON)

Challenge Exercise: Release Management with Hotfixes

This exercise simulates a real-world release management scenario:

  1. Create a new Git repository with several files representing a software project
  2. Create a develop branch from main
  3. Create several feature branches from develop, implement them, and merge them back into develop
  4. Create a release/1.0 branch from develop
  5. Make some release preparation commits on the release branch
  6. Merge release/1.0 into main and tag it as v1.0.0
  7. Continue development on develop (add new features)
  8. Discover a critical bug in the released version
  9. Create a hotfix branch from the release tag, fix the bug, and commit
  10. Merge the hotfix into both main and develop
  11. Tag the new version on main as v1.0.1
  12. Visualize and explain the resulting repository structure

Further Reading