Why Git Merge Works the Way It Does

Why Git Merge Works the Way It Does

Under the hood of Git's branching engine — exploring two-way vs three-way merges, Lowest Common Ancestor (LCA) graph search, merge conflict index stages, and C-Git's ORT merge strategy.

Every software engineer performs git merges daily. We type git merge feature, resolve occasional conflicts, and push our code. But what is Git actually doing under the hood when it merges two branches?

How does Git know which line changes are additions, which are deletions, and which are actual conflicts? The answer is not a simple line-by-line diff. Git relies on a graph-theory approach known as a **Three-Way Merge**, powered by identifying the **Lowest Common Ancestor (LCA)** in a Directed Acyclic Graph (DAG) of commits. In this deep dive, we explore why simple two-way diffs fail, translate the mathematical formulas of three-way merging, trace how Git represents conflicts inside the staging index, and analyze C-Git's modern, highly optimized ORT (Ours, Recursive, Three-way) merge strategy.


1. The Intuition: The Split and the Reunion

1.1 The Manuscript Analogy

Imagine two writers, Alice and Bob, who co-author a manuscript. They start with an initial draft (Commit $A$). Alice takes a copy and adds a chapter (Commit $B$). Simultaneously, Bob takes a copy and fixes typos in the preface (Commit $C$). To reunite their work into a final book (Merge Commit $M$), we cannot simply compare Alice's draft ($B$) directly against Bob's draft ($C$). If a paragraph exists in Alice's draft but not in Bob's, did Alice add it, or did Bob delete it? Comparing only their final drafts creates complete ambiguity.

To resolve the ambiguity, we must look at the **Common Base** ($A$) from which they both branched. If a paragraph is present in Alice's draft but missing in the base $A$, we know Alice *added* it. If a paragraph is present in the base $A$ but missing in Bob's draft, we know Bob *deleted* it. This is the core intuition behind three-way merging: we compare three files (Base, Ours, Yours) to compute the correct merge state.

flowchart TD A["Base Commit (A)"] B["Ours / Main (B)"] C["Yours / Feature (C)"] M["Merge Commit (M)"] A --> B A --> C B --> M C --> M

Diagram: The branching commit history forming a DAG where Commit A is the Lowest Common Ancestor.

Pitfall — Blindly running force-push after rebase: Using `git push --force` after resolving merge conflicts or rebasing branches can overwrite commits pushed by teammates in the meantime. Always use `git push --force-with-lease` which aborts the push if the remote branch contains new commits you haven't fetched yet.


2. Three-Way Merge vs Two-Way Merge

2.1 The Two-Way Diff Limitation

A **Two-Way Merge** compares only two files: file $O$ (Ours) and file $Y$ (Yours). If a line differs between the two files, a two-way merge algorithm cannot determine which branch made the modification. This limitation makes automated merging practically impossible, forcing developers to manually resolve every single file difference.

A **Three-Way Merge** introduces a third file: file $B$ (Base), representing the closest shared ancestor of $O$ and $Y$. By comparing $O$ and $Y$ against $B$, Git classifies line changes into four distinct states:

  • Unchanged: The line is identical in $B$, $O$, and $Y$. The merged output retains the line.
  • Ours Modified: The line changed in $O$ but remains identical to $B$ in $Y$. The merged output adopts the change from $O$.
  • Yours Modified: The line changed in $Y$ but remains identical to $B$ in $O$. The merged output adopts the change from $Y$.
  • Conflict: The line changed in both $O$ and $Y$ to different values. The merge algorithm pauses and requests developer intervention.

Pitfall — Merging unrelated histories: Running `git merge` on two branches that share no common parent commit fails with the error `fatal: refusing to merge unrelated histories`. This typically happens when initializing a local repository and trying to merge a remote repo with independent commit histories. Bypassing this with `--allow-unrelated-histories` can lead to massive merge conflicts.


3. Locating the Merge Base: Lowest Common Ancestor (LCA)

3.1 Graph Search in the Commit DAG

To execute a three-way merge, Git must identify the **Merge Base** (the Lowest Common Ancestor). Because Git's commit history is stored as a Directed Acyclic Graph (DAG) where nodes are commits and edges point to parent commits, finding the LCA requires walking backward through parent pointers starting from the two branch tips.

The merge base is defined as the closest common ancestor in terms of topological distance. When a commit history is straightforward, a single LCA is located. However, in complex branch topologies (e.g. criss-cross merges), multiple common ancestors can exist simultaneously.

flowchart TD X["Commit X (Base 1)"] Y["Commit Y (Base 2)"] A["Main Branch Tip (Ours)"] B["Feature Branch Tip (Yours)"] X --> A Y --> A X --> B Y --> B

Diagram: A criss-cross merge topology resulting in two distinct common ancestors (X and Y) with no single lowest common ancestor.

3.2 Topological Search and Commit-Graph Binary Indexing

Finding the Lowest Common Ancestor in a commit graph with hundreds of thousands of nodes is a computational challenge. Naive breadth-first searches (BFS) that traverse parent commit references recursively are slow because they require opening and parsing thousands of separate loose object files or packfile indexes on disk.

Modern Git solves this by using the **Commit-Graph** file (`.git/objects/info/commit-graph`), a single binary file that indexes commit relationships, generations, and topological levels. When searching for the merge base, Git loads the commit-graph into memory and runs a modified topological sorting algorithm (Kahn's or Tarjan's algorithm) based on **Generation Numbers**:

$$ \text{Gen}(C) = 1 + \max \left( \text{Gen}(P_1), \, \text{Gen}(P_2), \, \dots \right) $$

The graph traversal walks backward, keeping commit nodes sorted in descending order of generation numbers. By stopping the search when the generation number of active commit nodes drops below the maximum generation number of known common ancestors, Git limits search depth, finding the merge base in less than 10 milliseconds even on repositories with millions of commits.

Pitfall — Resolving criss-cross merges with default strategies: In a criss-cross history, selecting either ancestor `X` or `Y` arbitrarily as the merge base can result in spurious merge conflicts or silent regressions. Git solves this using the Recursive merge strategy, which merges `X` and `Y` into a virtual common ancestor commit first, then uses that virtual commit as the merge base.


4. The Three-Way Merge Diffing Equations

4.1 Mathematical Evaluation Matrix

Let $B$ represent the value of a line in the Base file, $O$ represent the value in Ours, and $Y$ represent the value in Yours. The three-way merge decision function $f(B, O, Y)$ is defined by the following mathematical mapping:

$$ f(B, O, Y) = \begin{cases} O & \text{if } O = Y \quad (\text{Identical changes or no change}) \\ O & \text{if } B = Y \land B \neq O \quad (\text{Only Ours modified the line}) \\ Y & \text{if } B = O \land B \neq Y \quad (\text{Only Yours modified the line}) \\ \text{Conflict}(O, Y) & \text{if } B \neq O \land B \neq Y \land O \neq Y \quad (\text{Both modified differently}) \end{cases} $$

This mathematical matrix guarantees that if only one side modified the line, that modification is automatically accepted. A conflict is triggered *only* when the line differs on both sides and neither matches the shared base.

4.2 Pre-requisite: Myers Diff and Longest Common Subsequence

Before Git can apply the three-way merge decision matrix, it must align the files line-by-line. It does this by running the **Myers Diff Algorithm** (an $O(ND)$ time complexity shortest-path search in an edit graph) or calculating the **Longest Common Subsequence (LCS)** matrix between the files:

$$ \text{LCS}(i, j) = \begin{cases} \text{LCS}(i-1, j-1) + 1 & \text{if } X[i] = Y[j] \\ \max(\text{LCS}(i-1, j), \text{LCS}(i, j-1)) & \text{otherwise} \end{cases} $$

By mapping files to their LCS, Git groups the code into unchanged blocks, deleted sequences, and inserted blocks. The three-way merge matrix is then evaluated strictly on the aligned blocks, preventing misaligned diff lines from corrupting the merge output.


5. How Merge Conflicts Occur & How Git Marks Them

5.1 Conflict Markers and Git Index Stages

When the merge engine encounters a conflict state, it does not write a final commit. Instead, it writes **conflict markers** directly into the working directory files and updates the **Git Index** (staging area) with three distinct versions of the conflicting file:

  • Stage 1 (Ancestor): The file version from the merge base ($B$).
  • Stage 2 (Ours): The file version from your current branch ($O$).
  • Stage 3 (Yours): The file version from the branch being merged ($Y$).
<<<<<<< HEAD
public void process() { // Your code (Stage 2)
=======
public void run() { // Incoming code (Stage 3)
>>>>>>> feature-branch

Pitfall — Forgetting to clear stage 1/2/3 index files: When resolving conflicts manually, simply editing the file text does not fully notify Git. The file remains locked in stages 1, 2, and 3 inside the index. You must run `git add ` to collapse the three staging versions back into a single clean Stage 0 entry, allowing `git commit` to proceed.


6. Fast-Forward vs Merge Commits

6.1 Fast-Forward Merges

A **Fast-Forward (FF)** merge occurs when the target branch tip is a direct descendant of the current branch tip. Because no divergent commit history exists, Git does not execute a three-way merge. Instead, it simply moves the current branch pointer forward to point to the target branch commit. No new merge commit is created.

If the histories have diverged (both branches contain unique commits since the split), a fast-forward is mathematically impossible. Git is forced to perform a three-way merge, creating a new **Merge Commit** featuring two parent references. The divergence relation can be expressed as:

$$ \text{Diverged}(O, Y) \iff \text{LCA}(O, Y) \neq O \land \text{LCA}(O, Y) \neq Y $$

6.2 Preserving History context via --no-ff

Even when a fast-forward merge is possible, teams often configure Git to force a merge commit using `git merge --no-ff feature`. Without `--no-ff`, fast-forwarding leaves no record of the feature branch's existence; its commits blend directly into the main timeline. Forcing a merge commit creates a permanent visual grouping in the DAG, showing exactly when a feature was integrated.

Pitfall — Rebase-and-merge flattening commits: While rebasing a feature branch and fast-forwarding it onto main keeps history strictly linear, it destroys parent link records. If a feature contains experimental or broken steps, rebasing flat makes it extremely difficult to identify and revert the entire feature unit in production using a single `git revert` command. Use merge commits for complete features.


7. Advanced: C-Git's ORT (Ours, Recursive, Three-way) Merge Strategy

7.1 The Modern Merge Engine

For over a decade, Git's default merge engine was the `recursive` strategy. In Git 2.34, it was replaced by **ORT (Ours, Recursive, Three-way)**. Written in pure C, ORT was designed to address performance limitations in large monorepos. ORT optimizes merges through:

  • Rename Detection Caching: Renaming files during a merge requires $O(N \times M)$ directory comparisons. ORT caches rename candidates, reducing merge times by up to $100\times$ on large refactors.
  • Conflict Resolution Deferral: Avoids writing partial states to the disk staging area until all file conflicts are computed, preventing expensive I/O operations.
  • Virtual Ancestor Synthesis: Automatically creates temporary virtual merge commits in memory when resolving multiple merge bases in criss-cross branches.

7.2 Under the Hood of ORT Rename Detection

During large refactors, files are often moved across directories while simultaneously being edited on another branch. C-Git's ORT strategy detects renames using similarity index algorithms based on the Levenshtein distance or hashing algorithms comparing block similarity. Once ORT detects that `/src/old.java` in the Base commit matches `/src/new.java` in Yours with a similarity score $S \ge 50\%$, it maps Ours changes made to `/src/old.java` directly onto the new file location `/src/new.java` automatically.

ORT caches these similarity computations across the entire merge session. If a criss-cross merge contains multiple merge bases, ORT only calculates similarity mappings once and reuses them during the virtual ancestor synthesis steps, eliminating redundant CPU-heavy diff operations.

Pitfall — Lowering rename detection thresholds too far: Configuring `git config merge.renameLimit` to extremely high values or setting `merge.renames` to search for very low similarity percentages (e.g. $<30\%$) forces Git to run expensive pairwise file diffs, freezing the CPU for minutes on large monorepos. Keep the default threshold at $50\%$ similarity.


8. Advanced: Staging Area Internals During Conflict

8.1 Staged File Inspection

When a merge conflict halts, you can inspect the raw files stored in the Git Index stages using `git ls-files -u`:

$ git ls-files -u
100644 8a3f8b... 1 src/App.java # Stage 1: Common Base (B)
100644 c2b3a4... 2 src/App.java # Stage 2: Current HEAD (Ours)
100644 e5f6a7... 3 src/App.java # Stage 3: Incoming Branch (Yours)

Using `git show :1:src/App.java` prints the raw contents of the common base version, allowing tools to perform three-way visual comparisons directly from memory without writing temporary files to the disk.

8.2 Programmatic Conflict Resolution via Checkout

When automatic merge conflict resolution is needed in CI/CD pipelines or scripts, you can programmatically bypass manual editing of conflict markers. To resolve conflicts by choosing one branch entirely, use the staging checkout commands:

# Option A: Accept Ours version (Current branch)
$ git checkout --ours src/App.java
$ git add src/App.java # Collapses to Stage 0
 
# Option B: Accept Yours version (Incoming branch)
$ git checkout --theirs src/App.java
$ git add src/App.java # Collapses to Stage 0

These checkout commands write the exact blob reference from Stage 2 or Stage 3 directly into the working directory file. Running `git add` afterwards updates the index, clearing Stage 1, 2, and 3 entries and placing the chosen blob reference at Stage 0, signaling Git that the conflict is fully resolved.


9. Git Integration Strategy Comparison

The table below compares common Git strategies for integrating changes across branches:

Strategy History Type Preserves Commit IDs? Best Practice Usage
`git merge` Divergent / Non-linear YES Integrating feature branches into shared main branches
`git rebase` Linear (Rewrites history) NO (Creates new commit hashes) Cleaning up local branch history prior to merging
`git merge --squash` Single commit result NO Keeping main branch history compact and clean
`git cherry-pick` Selective commit copy NO Porting hotfixes from main back to release branches

10. Interactive: Three-Way Merge Simulator

Click "Merge Branches" to run the Lowest Common Ancestor (LCA) search and execute a three-way merge simulation on the commit DAG:

Simulation Idle. Ready to merge...
1. Locate Merge Base (LCA)
Idle
2. Read Ours (HEAD Branch)
Idle
3. Read Yours (Feature Branch)
Idle
4. Generate Merge Commit
Idle

11. Merge Strategy Performance Comparison

The chart below compares execution times (in milliseconds) of different merge engines on a repository containing 10,000 files with rename detection active:


12. Frequently Asked Questions

Q1: How does Git determine if a change is a deletion or an addition?

Git compares both branches against the shared merge base. If a line exists in the base but is missing in Ours, it is a deletion. If it doesn't exist in the base but exists in Yours, it is an addition.

Q2: What is a fast-forward merge?

A merge that occurs when there is no divergent history: the target branch tip is a direct descendant of the current branch. Git simply moves the pointer forward without creating a new merge commit.

Q3: Why did Git switch its default merge engine to ORT?

Because the legacy `recursive` engine was too slow on large monorepos. ORT caches rename detections and optimizes index access, reducing merge times from minutes to milliseconds on large codebases.

Q4: What are stages 1, 2, and 3 in the Git index?

Stage 1 is the file version from the common base. Stage 2 is your current branch version (Ours). Stage 3 is the version from the branch being merged (Yours).

Q5: How does the recursive merge strategy handle multiple merge bases?

It merges the common ancestors together to create a new virtual common ancestor commit in memory, then uses that virtual commit as the merge base for the final three-way merge.

Q6: What is a criss-cross merge?

A history where two branches have merged from each other in cross directions, resulting in multiple lowest common ancestors in the commit graph.

Q7: Can a rebase trigger merge conflicts?

Yes. Rebase applies your commits one-by-one onto the target branch tip. Each individual commit application performs a three-way merge step and can trigger conflicts.

Q8: How do I abort an active merge conflict state?

Run `git merge --abort` to return your working directory and staging index to their pre-merge states.

Post a Comment

Previous Post Next Post