CrackedRuby CrackedRuby

Overview

Git provides two primary methods for integrating changes from one branch into another: merge and rebase. These operations solve the same problem—combining divergent lines of development—but differ fundamentally in how they handle commit history.

A merge operation creates a new commit that ties together the histories of two branches. This merge commit has two parent commits, preserving the complete branching structure and showing exactly when integration occurred. The command git merge feature-branch executed from the main branch creates this merge commit.

A rebase operation moves or "replays" commits from one branch onto another base commit. Instead of creating a merge commit, rebase rewrites history by creating new commits with different parent references. The command git rebase main executed from a feature branch moves the feature branch commits to the tip of main.

The distinction matters because Git branches are simply pointers to commits, and commits form a directed acyclic graph (DAG) through their parent relationships. Merge preserves the original DAG structure, while rebase modifies it by creating new commits with different parents.

Consider a repository where main has advanced while a feature branch was under development:

      C---D   feature
     /
A---B---E---F   main

After merging feature into main, the history shows:

      C---D
     /     \
A---B---E---F---G   main

Commit G is the merge commit with parents F and D. After rebasing feature onto main, the history becomes:

A---B---E---F---C'---D'   main

Commits C' and D' are new commits with the same changes as C and D but different parent references and SHA-1 hashes.

Key Principles

Merge operates by finding the common ancestor of two branches, computing the differences from that ancestor to each branch tip, and creating a three-way merge. Git's merge algorithm identifies the merge base (most recent common ancestor), calculates the diff from base to branch A, calculates the diff from base to branch B, then applies both sets of changes. Conflicts occur when both branches modify the same lines.

The merge commit records the integration point with two parent pointers. This preserves causality—the history shows that development occurred in parallel and was later integrated. The merge commit represents the act of integration itself, not new development work.

Rebase operates by identifying commits unique to the current branch, temporarily removing them, advancing the branch pointer to the new base, then replaying each commit one by one. Each replayed commit becomes a new commit object with a new SHA-1 hash because its parent has changed. The commit author, timestamp, and message remain the same, but Git considers it a different commit.

The fundamental principle distinguishing these operations: merge preserves history exactly as it happened, while rebase rewrites history to create a cleaner narrative. Merge says "these two lines of development were integrated at this point." Rebase says "these changes were always based on the current state of the target branch."

Rebase creates a linear history, which simplifies log viewing and bisecting. The trade-off is that the linear history is a fiction—it doesn't reflect the actual development sequence. Merge creates an accurate historical record at the cost of a more complex graph structure.

History rewriting through rebase has a critical implication: never rebase commits that have been pushed to a shared repository and that others may have based work on. Rewriting shared history causes divergent histories across team members' repositories, requiring force pushes that can overwrite others' work.

The golden rule of rebasing: only rebase commits that exist solely in your local repository. Once commits are shared, treat them as immutable. This rule exists because other developers may have those commits in their history. If you rewrite those commits, their copies become orphaned—they point to commits that no longer exist in the canonical repository.

Fast-forward merges represent a special case where no merge commit is necessary. If the target branch has not diverged from the feature branch (the feature branch contains all commits from the target), Git simply moves the target branch pointer forward. This occurs automatically unless explicitly disabled.

A---B---C   main
         \
          D---E   feature

# After fast-forward merge
A---B---C---D---E   main, feature

Implementation Approaches

Git provides multiple merge strategies accessed via the -s flag. The recursive strategy (default for two branches) performs a three-way merge and handles renames. The recursive strategy has options: -X ours automatically resolves conflicts by choosing the current branch's version, while -X theirs chooses the incoming branch's version.

# Standard merge with recursive strategy (default)
git merge feature-branch

# Merge favoring current branch on conflicts
git merge -X ours feature-branch

# Merge favoring incoming branch on conflicts  
git merge -X theirs feature-branch

The octopus strategy merges more than two branches simultaneously. Git uses this automatically when merging multiple branches at once. The octopus strategy refuses to proceed if manual conflict resolution would be necessary.

# Merge three feature branches at once
git merge feature-a feature-b feature-c

The --no-ff flag forces creation of a merge commit even when fast-forward is possible. This preserves information about the feature branch's existence and groups related commits together.

# Force merge commit for clear feature boundary
git merge --no-ff feature-branch

The --squash option combines all changes from the feature branch into a single commit on the target branch without creating a merge commit. This creates a clean history at the cost of losing individual commit history from the feature branch.

# Collapse feature branch into single commit
git merge --squash feature-branch
git commit -m "Add feature X with all changes"

Standard rebase replays commits from the current branch onto a new base. Each commit is applied in sequence, with conflicts resolved individually.

# Rebase current branch onto main
git checkout feature-branch
git rebase main

Interactive rebase (-i flag) provides control over how commits are replayed. The interactive interface allows reordering, editing, squashing, or dropping commits. This mode is essential for cleaning up commit history before sharing work.

# Interactive rebase of last 5 commits
git rebase -i HEAD~5

The interactive rebase editor shows commands:

pick a1b2c3d First commit message
pick e4f5g6h Second commit message
pick i7j8k9l Third commit message

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit message
# e, edit = use commit, but stop for amending
# s, squash = meld into previous commit
# f, fixup = like squash, but discard message
# d, drop = remove commit

Changing pick to squash combines that commit with the previous one:

pick a1b2c3d First commit message
squash e4f5g6h Second commit message
pick i7j8k9l Third commit message

This results in two commits instead of three, with the first containing changes from both a1b2c3d and e4f5g6h.

The --onto option rebases commits onto a different base than the upstream branch. This is useful for moving a branch that was based on the wrong commit or for extracting a subsection of commits.

# Move commits from feature onto main, skipping experimental base
git rebase --onto main experimental feature

This command finds commits reachable from feature but not from experimental, then replays them onto main.

The --autosquash option works with commits created using git commit --fixup or git commit --squash. When rebasing interactively with autosquash, these fixup commits automatically appear in the correct position to be squashed.

# Create fixup commit that will automatically squash
git commit --fixup abc123
git rebase -i --autosquash main

The --preserve-merges option (deprecated) maintains merge commits during rebase. The modern approach uses --rebase-merges, which recreates the branch structure including merges.

# Rebase while maintaining merge commits
git rebase --rebase-merges main

Abort and continue operations handle interrupted rebase or merge operations. When conflicts occur during rebase, Git pauses and allows resolution before continuing.

# Abort rebase and return to pre-rebase state
git rebase --abort

# After resolving conflicts, continue rebase
git add resolved-file.rb
git rebase --continue

# Skip problematic commit during rebase
git rebase --skip

Practical Examples

A typical feature branch workflow using merge starts with creating a branch from main, making commits, then integrating back to main.

# Start feature development
git checkout main
git pull origin main
git checkout -b feature/user-authentication

# Make changes
echo "class AuthService" > auth_service.rb
git add auth_service.rb
git commit -m "Add authentication service skeleton"

echo "def authenticate(token)" >> auth_service.rb
git add auth_service.rb
git commit -m "Implement token authentication"

# Meanwhile, main has advanced
# Integrate main's changes into feature branch
git checkout feature/user-authentication
git merge main

# Resolve any conflicts, then continue
git add resolved-files
git commit

# Complete feature and merge to main
git checkout main
git merge --no-ff feature/user-authentication
git push origin main

The same workflow using rebase creates a linear history:

# Start feature development (same as above)
git checkout main
git pull origin main
git checkout -b feature/user-authentication

# Make changes (same commits as above)
echo "class AuthService" > auth_service.rb
git add auth_service.rb
git commit -m "Add authentication service skeleton"

echo "def authenticate(token)" >> auth_service.rb
git add auth_service.rb
git commit -m "Implement token authentication"

# Main has advanced - rebase instead of merge
git checkout feature/user-authentication
git rebase main

# Resolve any conflicts per-commit
# After resolving each conflict:
git add resolved-files
git rebase --continue

# Complete feature - fast-forward merge possible
git checkout main
git merge feature/user-authentication
git push origin main

Interactive rebase cleans up commit history before sharing. Consider a feature branch with work-in-progress commits:

# Feature branch with messy history
git log --oneline
a1b2c3d Fix typo in comment
e4f5g6h WIP: debugging authentication
i7j8k9l Add authentication logic
m0n1o2p Initial authentication setup

# Clean up with interactive rebase
git rebase -i HEAD~4

# In editor, squash work-in-progress commits
pick m0n1o2p Initial authentication setup
squash i7j8k9l Add authentication logic
squash e4f5g6h WIP: debugging authentication
pick a1b2c3d Fix typo in comment

# Result: two clean commits instead of four messy ones

Handling conflicts during rebase requires resolving each commit individually. If main has modified the same code the feature branch changed, conflicts arise during replay.

git checkout feature/refactor-models
git rebase main

# Conflict in app/models/user.rb
# CONFLICT (content): Merge conflict in app/models/user.rb
# error: could not apply abc123... Refactor user validation

# Open user.rb and resolve conflicts
# File shows:
# <<<<<<< HEAD
# def validate_email
#   # New validation logic from main
# =======
# def validate_email
#   # Your refactored logic
# >>>>>>> abc123... Refactor user validation

# After resolving:
git add app/models/user.rb
git rebase --continue

# If resolution was wrong and you want to try again:
git rebase --abort
git rebase main

Rebasing a feature branch that itself has sub-branches requires care. The --rebase-merges option maintains the branch structure:

# Feature branch with sub-feature
#     D---E   sub-feature
#    /
# A---B---C   feature
#            \
#             F---G   main

git checkout feature
git rebase --rebase-merges main

# Result maintains sub-feature branch:
#     D'---E'   sub-feature
#    /
# F---G---A'---B'---C'   feature, main

A Ruby project using Git programmatically demonstrates merge operations through the rugged gem:

require 'rugged'

repo = Rugged::Repository.new('.')

# Merge feature branch into main
repo.checkout('main')
main_commit = repo.head.target
feature_commit = repo.branches['feature/new-api'].target

# Perform merge
index = repo.merge_commits(main_commit, feature_commit)

if index.conflicts?
  puts "Conflicts detected:"
  index.conflicts.each do |conflict|
    puts "  #{conflict[:ancestor][:path]}"
  end
else
  # Create merge commit
  options = {
    parents: [main_commit, feature_commit],
    tree: index.write_tree(repo),
    message: "Merge feature/new-api into main",
    author: { name: "CI System", email: "ci@example.com", time: Time.now },
    committer: { name: "CI System", email: "ci@example.com", time: Time.now }
  }
  
  merge_commit = Rugged::Commit.create(repo, options)
  repo.head.target = merge_commit
end

Design Considerations

The choice between merge and rebase depends on team workflow, history preferences, and collaboration patterns. Merge prioritizes accurate historical record, while rebase prioritizes readable history.

Use merge when working on shared branches where others may have based work on your commits. Merge preserves the existing commit graph, preventing history divergence across team members. Long-running feature branches that others contribute to should always use merge for integration.

Use rebase for local cleanup of unpublished commits. Before pushing a feature branch for code review, rebase interactively to combine related commits, improve commit messages, and remove debugging commits. This creates a clean, reviewable history.

Use rebase when updating a feature branch with changes from main. This keeps feature branch history linear and makes the eventual merge cleaner. However, if the feature branch is shared, coordinate with team members or use merge instead.

The frequency of integration affects the choice. Projects that integrate frequently (multiple times per day) often prefer rebase to avoid excessive merge commits cluttering the history. Projects that integrate less frequently may prefer merge to clearly mark integration points.

History readability versus historical accuracy represents the core trade-off. Merge history shows exactly what happened: when branches were created, how long they existed, when they were integrated. Rebase history shows a simplified narrative that may not reflect actual development sequence but is easier to understand at a glance.

Team size and distribution influence the decision. Small, co-located teams can coordinate rebase usage more easily. Large, distributed teams often prefer merge to avoid coordination overhead and reduce risk of history divergence.

Release management strategies affect the choice. Projects that maintain multiple release branches often use merge to clearly track what changes exist in which releases. The merge commits serve as markers for cherry-picking or backporting.

Continuous integration systems may enforce specific workflows. Some teams configure CI to reject non-linear histories (requiring rebase), while others require merge commits for audit trails.

The impact on git bisect differs between approaches. Linear histories from rebase make bisecting simpler—each commit represents a complete state. Merge histories require bisecting through the merge commits, which may or may not contain the bug.

Consider the reverting strategy when choosing. Reverting a merge commit requires git revert -m to specify which parent to revert to, which is more complex than reverting a regular commit. Rebased histories make reverting individual changes more straightforward.

Code review processes affect the decision. If code review happens before merge, rebase allows cleaning up based on feedback without creating "address review comments" commits. If code review happens after merge, those cleanup commits become part of the permanent history.

Common Patterns

The feature branch workflow with merge maintains separate feature branches that are merged when complete. This pattern clearly shows when features were integrated and maintains feature isolation.

# Developer A
git checkout -b feature/payment-processing
# ... make commits ...
git checkout main
git pull origin main
git checkout feature/payment-processing
git merge main  # Stay current with main
# ... more commits ...
git checkout main
git merge --no-ff feature/payment-processing
git push origin main

# Developer B can see clear feature boundary in history
git log --graph --oneline
*   2a3b4c5 Merge branch 'feature/payment-processing'
|\
| * 6d7e8f9 Add payment validation
| * 0a1b2c3 Implement payment API
| * 4d5e6f7 Add payment models
|/
* 8g9h0i1 Previous main commit

The feature branch workflow with rebase creates a linear history where feature commits appear to have been based on the latest main:

# Developer workflow
git checkout -b feature/reporting
# ... make commits ...
git fetch origin
git rebase origin/main  # Update from main
# ... more commits ...
git rebase -i origin/main  # Clean up history
git checkout main
git merge feature/reporting  # Fast-forward merge
git push origin main

# Result: linear history
git log --oneline
2a3b4c5 Add report export functionality
6d7e8f9 Implement report generation
0a1b2c3 Add report models
8g9h0i1 Previous main commit

Trunk-based development uses short-lived feature branches (hours to a day) with frequent integration. This pattern typically uses rebase to maintain a linear main branch:

# Create short-lived feature
git checkout -b feature/fix-user-validation
# ... make focused changes ...
git rebase main
git checkout main
git merge feature/fix-user-validation
git push origin main
git branch -d feature/fix-user-validation

The git-flow model uses multiple long-lived branches (main, develop, release) with different merge strategies for each. Feature branches merge into develop, release branches merge into both main and develop:

# Feature to develop: can use either merge or rebase
git checkout -b feature/new-dashboard develop
# ... commits ...
git checkout develop
git merge --no-ff feature/new-dashboard

# Release to main and develop: always merge
git checkout -b release/1.5.0 develop
# ... version bump, final fixes ...
git checkout main
git merge --no-ff release/1.5.0
git tag -a v1.5.0
git checkout develop
git merge --no-ff release/1.5.0

The commit-per-review pattern creates a clear history where each commit represents a reviewed unit of work. Developers squash or rebase interactively before requesting review:

# Before review: messy history
git log --oneline
a1b2c3d Fix linting errors
e4f5g6h Address feedback
i7j8k9l WIP checkpoint
m0n1o2p Feature implementation

# Clean up for review
git rebase -i HEAD~4
# Squash all into one commit with clear message
pick m0n1o2p Implement user preference system
fixup i7j8k9l WIP checkpoint
fixup e4f5g6h Address feedback
fixup a1b2c3d Fix linting errors

# After review: single reviewable commit
git log --oneline
z9y8x7w Implement user preference system

The emergency hotfix pattern requires fast integration to production. Hotfix branches typically use merge to clearly mark the emergency fix in history:

# Create hotfix from main
git checkout -b hotfix/security-patch main
# ... fix critical security issue ...
git commit -m "Fix SQL injection vulnerability in user search"

# Merge to main
git checkout main
git merge --no-ff hotfix/security-patch
git tag -a v1.4.1

# Also merge back to develop
git checkout develop
git merge --no-ff hotfix/security-patch

Common Pitfalls

Force pushing after rebasing shared commits creates divergent histories. When a developer rebases commits that others have pulled, the rebased commits have different SHA-1 hashes. Others' repositories still reference the old commits, creating a divergent history.

# Developer A pushes feature branch
git push origin feature/api-updates

# Developer B pulls and bases work on it
git checkout feature/api-updates
git checkout -b feature/api-updates-part2
git commit -m "Build on API updates"

# Developer A rebases (BAD - branch is shared)
git checkout feature/api-updates
git rebase main
git push --force origin feature/api-updates

# Developer B's repository now has orphaned commits
# Their feature/api-updates-part2 is based on commits that no longer exist
# in origin/feature/api-updates

This situation requires Developer B to rebase their work onto the force-pushed branch, potentially causing conflicts and lost work.

Rebasing merge commits without --rebase-merges flattens the history and loses the merge structure. This makes it impossible to see that parallel development occurred:

# History with merge commits
git log --graph --oneline
*   a1b2c3d Merge feature into main
|\
| * e4f5g6h Feature commit 2
| * i7j8k9l Feature commit 1
* | m0n1o2p Main commit
|/
* q3r4s5t Base commit

# Rebase without --rebase-merges flattens
git rebase upstream/main
# Result: linear history loses information about parallel work
* e4f5g6h' Feature commit 2
* i7j8k9l' Feature commit 1
* m0n1o2p Main commit
* q3r4s5t Base commit

Losing work during interactive rebase happens when commits are marked as drop or when conflicts are resolved incorrectly. Once a rebase completes, the dropped commits are only recoverable through the reflog:

# Interactive rebase drops important commit by mistake
git rebase -i HEAD~5
# Accidentally mark commit as "drop" instead of "pick"

# Realize mistake after rebase completes
# Check reflog to find lost commit
git reflog
a1b2c3d HEAD@{0}: rebase -i (finish): returning to refs/heads/feature
e4f5g6h HEAD@{1}: rebase -i (pick): ...
i7j8k9l HEAD@{2}: commit: Important commit (dropped)

# Recover using cherry-pick
git cherry-pick i7j8k9l

Merge conflict resolution errors create commits that combine changes incorrectly. When both branches modify the same function differently, manual resolution may inadvertently break functionality:

# Main branch version
def calculate_total(items)
  items.sum { |item| item.price * item.quantity }
end

# Feature branch version
def calculate_total(items)
  items.sum { |item| item.price * item.quantity * (1 - item.discount) }
end

# Incorrect conflict resolution might create
def calculate_total(items)
  items.sum { |item| item.price * (1 - item.discount) }  # Lost quantity multiplication
end

Testing the merged code thoroughly catches these errors before they reach production.

Rebasing published commits that are part of a release creates problems for users who have checked out specific versions. Tags should point to immutable commits:

# BAD: Rebase after tagging
git tag v1.0.0
git push origin v1.0.0
git rebase main  # Rewrites commits that v1.0.0 references
git push --force origin feature

# Anyone who checked out v1.0.0 now has orphaned commits

Confusion about merge base affects both operations. Git determines the merge base (common ancestor) automatically, but in complex histories with multiple potential ancestors, Git's choice may not match expectations. The git merge-base command reveals what Git will use:

# Check what Git considers the merge base
git merge-base feature main
a1b2c3d

# If this isn't the expected base, use rebase --onto
git rebase --onto correct-base incorrect-base feature

Stale feature branches that haven't been updated with main changes accumulate conflicts. Resolving months of accumulated conflicts in a single merge or rebase session is error-prone. Regular integration prevents this:

# BAD: Feature branch untouched for 3 months
git checkout feature/old-work  # Last commit 90 days ago
git rebase main  # Hundreds of conflicts

# BETTER: Regular integration
git checkout feature/active-work
git rebase main  # Or git merge main
# Resolve small number of conflicts
# Continue working
# Repeat weekly or after significant main changes

Misunderstanding fast-forward merges leads to surprise when no merge commit appears. Developers expecting to see merge commits in history are confused when Git fast-forwards:

git merge feature-branch
# Expecting:
#   *   Merge commit
#   |\
#   | * Feature commit
#
# But Git fast-forwards:
#   * Feature commit (no merge commit)

# Force merge commit if needed
git merge --no-ff feature-branch

Reference

Command Comparison

Operation Merge Rebase
Basic command git merge branch-name git rebase branch-name
Creates new commit Yes (merge commit) No (rewrites commits)
Preserves original commits Yes No (new SHAs)
History structure Non-linear (graph) Linear
Safe for shared branches Yes No
Conflict resolution Once for all changes Per commit

Merge Strategy Options

Strategy Use Case Command
recursive Default for two branches git merge branch
ours Auto-resolve conflicts keeping current git merge -s ours branch
theirs Auto-resolve conflicts keeping incoming git merge -X theirs branch
octopus Merge multiple branches git merge branch1 branch2 branch3
subtree Merge subdirectory into root git merge -s subtree branch

Rebase Options

Option Purpose Example
-i, --interactive Choose actions for each commit git rebase -i HEAD~5
--onto Rebase onto different base git rebase --onto main dev feature
--autosquash Auto-arrange fixup commits git rebase -i --autosquash main
--rebase-merges Preserve merge commits git rebase --rebase-merges main
--skip Skip current commit during rebase git rebase --skip
--abort Cancel rebase, return to original git rebase --abort
--continue Continue after conflict resolution git rebase --continue

Interactive Rebase Commands

Command Effect Use Case
pick Use commit as-is Keep commit unchanged
reword Use commit, edit message Fix commit message
edit Use commit, stop for amending Modify commit content
squash Combine with previous, keep message Merge related commits
fixup Combine with previous, discard message Merge cleanup commits
drop Remove commit Delete unwanted commit
exec Run shell command Run tests between commits

Conflict Resolution

Command Purpose
git status Show conflicted files
git diff Show conflict markers
git checkout --ours file Use current branch version
git checkout --theirs file Use incoming branch version
git add file Mark conflict as resolved
git merge --abort Cancel merge operation
git rebase --abort Cancel rebase operation
git rerere Reuse recorded conflict resolutions

History Inspection

Command Purpose
git log --graph --oneline Visualize branch structure
git log --first-parent Show only merge commits
git show-branch Compare branch tips
git merge-base branch1 branch2 Find common ancestor
git reflog View reference log for recovery

Workflow Decision Matrix

Scenario Recommended Approach Rationale
Local unpublished commits Rebase Clean history, no collaboration impact
Shared feature branch Merge Prevents history divergence
Updating feature from main Rebase (if private) or Merge (if shared) Depends on branch sharing status
Long-running feature branch Merge Clear integration points
Hotfix to production Merge with --no-ff Clear audit trail
Code review cleanup Interactive rebase Clean, reviewable commits
Release branch to main Merge with --no-ff and tag Clear release marker
Short-lived feature (<1 day) Rebase then fast-forward merge Linear history
Multiple developers on branch Merge only Avoid coordination issues

Ruby Git Integration Commands

Rugged Method Purpose
Repository.new(path) Open repository
repo.merge_commits(ours, theirs) Compute merge result
repo.branches[name].target Get branch commit
Commit.create(repo, options) Create commit
repo.reset(commit, :hard) Move HEAD and working directory
repo.checkout(ref) Switch branches