Day 2: Branch Your Feature, Merge Your Changes — Navigating Divergent Histories Without Losing Work

Lesson 2 60 min

Day 2: Branch Your Feature, Merge Your Changes — Navigating Divergent Histories Without Losing Work

State Machine

Repository Merge State Machine CLEAN WORKING DIR Branches aligned / idle MERGE_IN_PROGRESS Evaluating LCA & Matrix git merge MERGE_CONFLICTED Awaiting user resolution Overlapping Edits Resolve manually & commit

Flowchart

Merge Verification Flow Merge Requested Execute LCA BFS Search Is LCA == Ours? Fast-Forward Pointer Yes Evaluate 3-Way Matrix No Flag Conflict Marker Conflict

Component Architecture

Divergence Engine Architecture Lowest Common Ancestor (LCA) Search Root (Base) Ours (main) Theirs BFS Back-traverse 3-Way Merge Decision Matrix Base Ours Theirs Result Val A Val B Val A Ours (B) Val A Val A Val C Theirs (C) Val A Val B Val C Conflict!

In Day 1, we initialized our local database—the Git repository—and inspected how it stores immutable snapshots as a Directed Acyclic Graph (DAG) of commits. We established that every commit is a cryptographic checkpoint pointing to its direct ancestors.

Today, we move from a single, linear timeline to a multi-writer environment. In distributed systems, allowing concurrent updates is non-negotiable for scaling write throughput. However, concurrency introduces the hardest problem in distributed state: divergence. When two actors make changes to the same logical state without coordination, we must eventually reconcile their histories.

We will build and analyze a custom visual conflict-resolution engine to understand exactly how Git handles this divergence. You will learn the mechanics of the Lowest Common Ancestor (LCA) search, contrast Fast-Forward merges with 3-Way Merges, and discover how Git's merge base algorithm prevents the silent data loss that plagues traditional distributed databases.

The Production Stakes: The Cost of Silent Divergence

In a distributed database like DynamoDB or Cassandra, concurrent writes to the same key can lead to divergence. If two nodes accept writes for the same user record simultaneously, the system must reconcile them.

Early distributed databases often relied on Last-Write-Wins (LWW), which uses wall-clock timestamps to resolve conflicts. In 2012, Apache Cassandra users famously documented incidents where minor NTP clock drift caused newer writes to be silently discarded by nodes with older clocks, resulting in quiet, untraceable data corruption.

Code
       [Ancestor: User Profile v1]
               /          
              /            
    [Writer A: v2a]     [Writer B: v2b]
    (Clock: 10:00:01)   (Clock: 10:00:00 due to drift)
                          /
                         /
         [LWW Resolution: v2a Wins]
         (Writer B's updates are silently lost)

Git rejects Last-Write-Wins entirely. It treats history as a first-class citizen. Instead of guessing which write is "newer" based on unreliable physical clocks, Git preserves both paths of execution as branches. When you merge, Git reconstructs the exact point where the histories diverged—the Lowest Common Ancestor—and uses it as a reference point to perform a 3-Way Merge. If a safe merge is mathematically impossible, Git pauses and forces human intervention, ensuring zero silent data loss.

The Intuition: The 3-Way Merge vs. 2-Way Diff

Why does Git require a 3-way merge? Why can we not simply compare the latest commit on main with the latest commit on your feature branch?

Imagine comparing two files:

  • File A (on main): database_port = 5432

  • File B (on feature): database_port = 5433

If you only perform a 2-way comparison between these two files, you cannot determine which branch changed the value, or if both did. Did main change it from 5432 to 5433, or did feature change it from 5433 to 5432?

To resolve this ambiguity, Git introduces a third file: the Lowest Common Ancestor (the base).

  • Base File: database_port = 5432

Now we have a complete picture:

  1. On main, the value is 5432. It matches the Base. No change was made.

  2. On feature, the value is 5433. It differs from the Base. A change was made.

Because only one side departed from the common ancestor, Git can safely auto-merge the file to 5433. This is the core magic of the 3-Way Merge.

Code
                  [Base: 5432]
                 /            
                /              
     [main: 5432]              [feature: 5433]
  (Unchanged from Base)       (Changed from Base)
                              /
                             /
               [Merged: 5433]
            (Safe Auto-Resolution)

The Architecture of Divergence

To implement this on your laptop, we represent the repository state as a graph of commits. Each commit object contains:

  1. A unique identifier (hash).

  2. A list of parent hashes (zero for root, one for standard commits, two or more for merge commits).

  3. A state payload (the filesystem snapshot).

Our divergence engine uses two primary algorithms:

1. The Lowest Common Ancestor (LCA) Search

To find the merge base of Commit $A$ and Commit $B$, we must find the closest shared ancestor in the DAG. We perform a breadth-first search (BFS) starting from both commits, tracking the distance to each ancestor. The first node visited by both search paths with the minimal path distance is our Lowest Common Ancestor.

2. The 3-Way Merge Decision Matrix

Once the LCA (Base) is identified, we compare the file states across Base, Ours (current branch), and Theirs (branch to merge):

State in BaseState in OursState in TheirsResolution Action
Value XValue XValue XNo change. Keep Value X.
Value XValue YValue XOnly Ours changed. Keep Value Y.
Value XValue XValue ZOnly Theirs changed. Keep Value Z.
Value XValue YValue YBoth changed identically. Keep Value Y.
Value XValue YValue ZConflict! Human resolution required.

Designing for Scale: Git vs. Database Engines

In our local exercise, finding the LCA takes microseconds. At hyperscale—such as monorepos managed by Microsoft or Google containing tens of millions of commits—traversing the DAG on every merge query is computationally prohibitive.

To solve this, modern Git hosting providers and scale-out file systems use Generation Numbers (also known as topological levels) cached inside a specialized binary file called the commit-graph. By storing the topological depth of every commit, Git can short-circuit the BFS traversal. If we are looking for the LCA of Commit $A$ (depth 100) and Commit $B$ (depth 50), the search algorithm knows it does not need to traverse any ancestors of Commit $A$ that have a depth greater than 50, saving millions of disk reads.

Code Deep Dive: The LCA and Merge Logic

Let us look at the core execution paths of our divergence engine. The following Python snippet demonstrates how we traverse the DAG backward to find the merge base using a dual BFS queue:

python
def find_lowest_common_ancestor(graph, commit_a_id, commit_b_id):
    visited_a = set()
    visited_b = set()
    queue_a = [commit_a_id]
    queue_b = [commit_b_id]

    while queue_a or queue_b:
        if queue_a:
            curr_a = queue_a.pop(0)
            if curr_a in visited_b:
                return curr_a
            visited_a.add(curr_a)
            queue_a.extend(graph[curr_a].parents)
            
        if queue_b:
            curr_b = queue_b.pop(0)
            if curr_b in visited_a:
                return curr_b
            visited_b.add(curr_b)
            queue_b.extend(graph[curr_b].parents)
            
    return None

Once the LCA is found, we evaluate the files using our decision engine. If both branches modified the same file to different contents, we generate a conflict marker, mimicking exactly what Git writes to your workspace:

python
def merge_files(base_files, our_files, their_files):
    merged = {}
    conflicts = {}
    all_keys = set(base_files.keys()) | set(our_files.keys()) | set(their_files.keys())

    for key in all_keys:
        val_base = base_files.get(key)
        val_our = our_files.get(key)
        val_their = their_files.get(key)

        if val_our == val_their:
            merged[key] = val_our
        elif val_our == val_base:
            merged[key] = val_their
        elif val_their == val_base:
            merged[key] = val_our
        else:
            conflicts[key] = f"<<<<<<< OURSn{val_our}n=======n{val_their}n>>>>>>> THEIRS"
            
    return merged, conflicts

Trade-off Analysis: Fast-Forward vs. Merge Commits

When merging histories that have not diverged—where the target branch is a direct descendant of the current branch—Git defaults to a Fast-Forward merge. It simply slides the branch pointer forward to match the target commit. No new merge commit is created.

Code
[Fast-Forward Merge]
Before:  main (Commit 1) <--- feature (Commit 2)
After:   main, feature (Commit 2)

The Trade-off:

  • Fast-Forward Wins: Keeps the commit history perfectly linear, clean, and easy to bisect when hunting down bugs.

  • Merge Commit Wins: Preserves the historical context that a group of commits was developed together as a discrete unit of work. It provides a natural checkpoint to revert an entire feature at once if a production incident occurs.

At hyperscale, teams balance this by enforcing a "Squash and Merge" policy on pull requests, which squashes the feature commits into a single node before fast-forwarding the target branch.

Now, let us transition to the Implementation Guide to construct this engine on your local machine and witness how it handles divergent histories under pressure.


Assignment: Implement a Merge Base Visualizer and Conflict Resolver

Your task is to extend the engine we build today. Currently, if a merge conflict occurs, our engine flags it and halts.

Modify the engine to implement a Three-Way Interactive Auto-Resolver. Your extended tool must:

  1. Detect a conflict in a configuration file (e.g., config.json).

  2. Parse the conflicted JSON payload.

  3. If the conflict is inside a JSON list (such as a list of active microservices), automatically resolve the conflict by performing a union of both lists, rather than failing.

Success Criteria:

  • Your visualizer must print the DAG showing the two divergent branches and their detected LCA.

  • When merging a JSON file containing lists, the output must show the combined list sorted alphabetically with no duplicates, and no conflict markers in that specific file.

  • Running your auto-resolver must output a clean, valid JSON file.

Hints:

  • Identify if the conflicting file has a .json extension.

  • Parse the base, ours, and theirs content using Python's json.loads().

  • If both ours and theirs modified a key whose value is a list, construct the merged value as sorted(list(set(ours_list) | set(theirs_list))).


Solution: Implementing the JSON List Union Resolver

To complete the assignment, integrate this resolution block into your file merger.

python
import json

def resolve_json_list_conflict(base_str, our_str, their_str):
    try:
        base_obj = json.loads(base_str) if base_str else {}
        our_obj = json.loads(our_str)
        their_obj = json.loads(their_str)
        
        resolved_obj = {}
        for key in set(our_obj.keys()) | set(their_obj.keys()):
            val_base = base_obj.get(key)
            val_our = our_obj.get(key)
            val_their = their_obj.get(key)
            
            if val_our == val_their:
                resolved_obj[key] = val_our
            elif isinstance(val_our, list) and isinstance(val_their, list):
                # Perform the union of lists to resolve conflict
                union_list = sorted(list(set(val_our) | set(val_their)))
                resolved_obj[key] = union_list
            elif val_our == val_base:
                resolved_obj[key] = val_their
            else:
                resolved_obj[key] = val_our  # Default fallback or raise conflict
                
        return json.dumps(resolved_obj, indent=2), True
    except Exception:
        return None, False

This elegant solution mirrors how modern enterprise development platforms build semantic merge tools to automatically resolve trivial changes in import statements or dependency files without interrupting developer velocity.

In our next lesson, Day 3: Push Your First Feature to GitHub, Then Deliberately Cause a Merge Conflict — and Resolve It Under Pressure, we will take these local graph concepts and project them onto a remote server, introducing network latency, upstream race conditions, and real-time remote conflict resolution.

Questions & Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *