Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Git & GitHub  Module 10 – Git internals

How Git computes diffs and merges

See how Git turns two snapshots into a diff with Myers or histogram, finds renames, merges from a merge base, and previews merges with git merge-tree.

  • Advanced
  • 30 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Compare the Myers, patience and histogram diff algorithms on the same change
  • Explain rename detection and its similarity threshold
  • Trace a three-way merge from the merge base and handle criss-cross histories
  • Compute a merge without touching the working tree with git merge-tree --write-tree
  • Find manual changes inside a merge commit with git log --remerge-diff

Before you start

What you’ll learn · Before you start

What you will learn

  • Compare the Myers, patience and histogram diff algorithms on the same change
  • Explain rename detection and its similarity threshold
  • Trace a three-way merge from the merge base and handle criss-cross histories
  • Compute a merge without touching the working tree with git merge-tree --write-tree
  • Find manual changes inside a merge commit with git log --remerge-diff

Before you start

On this page

Git stores snapshots, not changes, so every diff you read is computed when you ask for it. git diff compares two trees to find the paths that changed, pairs deleted and added files that are similar enough into renames, and then runs a line diff algorithm on each changed file: Myers by default, which looks for the fewest deleted and added lines, or minimal, patience or histogram, which can describe the same change differently. A merge compares three snapshots: the merge base, the newest commit both branches share, and the two branch tips. Wherever only one side changed something since the base, that change wins; where both sides changed the same region differently, there is a conflict. This lesson runs each step in real sessions: four algorithms on one change, rename detection at its threshold, five small merges to predict, a criss-cross history with two merge bases, a merge computed without a working tree, and a merge commit that quietly dropped a change.

From two snapshots to a list of changes

A commit records a tree, so a diff starts by comparing two trees entry by entry. An entry with the same ID on both sides holds the same content, so Git can skip it, folder and all, without reading it. What is left is a list of modified, added and deleted paths; rename detection then pairs some deletions with additions, and only after that does a line algorithm look inside each changed file.

git diff compares two trees, pairs deleted and added paths into renames, then runs a line diff algorithm on each changed file.Two snapshotsthe root trees of two commitsChanged pathsmodified, added, deletedRenames and copies founddeleted + added paths compared by similarityA line diff for each changed filemyers, minimal, patience or histogramThe patch you readhunks of - and + linescompare entry by entry;equal IDs are skipped whole-M, -C, diff.renameLimit--diff-algorithm

The steps from two snapshots to a patch

Text description of the diagram

The diagram shows the four steps git diff takes, from top to bottom.

  1. It starts from two snapshots: the root trees of the two commits being compared.
  2. It compares the trees entry by entry. An entry whose ID is the same in both trees is skipped whole, folder and all; the rest become a list of modified, added and deleted paths.
  3. It compares deleted paths with added paths and pairs those that are similar enough into renames, and, when asked, finds copies. The options -M and -C and the setting diff.renameLimit control this step.
  4. For each changed file, a line diff algorithm, myers by default, or minimal, patience or histogram, finds which lines to delete and which to add.
  5. The result is the patch you read: for each file, hunks of deleted lines marked with a minus and added lines marked with a plus.

Two consequences follow. A rename is never stored anywhere, so whether you see one depends on the options of the command that computes the diff. And the line algorithm is a choice too: the same two snapshots can produce different diffs that are all correct.

Four line-diff algorithms on one change

The billing module of a canteen has five functions. A programmer moved receipt() below tax() and, in the same commit, changed two of its lines to align the columns. Here are the two versions, then four algorithms on that commit:

billing.py before and after

Python (billing_v1.py) · billing_v1.py

MENU = {"idli": 30, "vada": 20, "chai": 10}


def price(item):
    return MENU[item]


def total(order):
    amount = 0
    for item in order:
        amount += price(item)
    return amount


def receipt(order):
    lines = []
    for item in order:
        lines.append(f"{item}: {price(item)}")
    lines.append(f"Total: {total(order)}")
    return "\n".join(lines)


def tax(order):
    amount = total(order)
    return round(amount * 0.05)


def count(order):
    amount = 0
    for item in order:
        amount += 1
    return amount

Python (billing_v2.py) · billing_v2.py

MENU = {"idli": 30, "vada": 20, "chai": 10}


def price(item):
    return MENU[item]


def total(order):
    amount = 0
    for item in order:
        amount += price(item)
    return amount


def tax(order):
    amount = total(order)
    return round(amount * 0.05)


def receipt(order):
    lines = []
    for item in order:
        lines.append(f"{item:<6}{price(item):>4}")
    lines.append(f"{'Total':<6}{total(order):>4}")
    return "\n".join(lines)


def count(order):
    amount = 0
    for item in order:
        amount += 1
    return amount
One commit, four diff algorithms Python · algorithms.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
cp ../../billing_v1.py billing.py
git add billing.py
git commit -q -m "Billing helpers"
cp ../../billing_v2.py billing.py
git commit -q -am "Move receipt below tax and align its columns"
# The same two snapshots under four algorithms: lines added, lines deleted
for algo in myers minimal patience histogram; do printf '%-10s' $algo; git diff --numstat --diff-algorithm=$algo HEAD~1 HEAD | cut -f1,2; done
git diff --diff-algorithm=myers HEAD~1 HEAD
git diff --diff-algorithm=histogram HEAD~1 HEAD
# --anchored keeps a line that occurs once on each side from appearing as deleted and added
git diff --numstat --anchored='def tax(order):' HEAD~1 HEAD
# --color-moved gives moved lines colours of their own; show_colors.py names them in words
git diff --color=always --color-moved=plain HEAD~1 HEAD | python3 ../../show_colors.py
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ cp ../../billing_v1.py billing.py
$ git add billing.py
$ git commit -q -m "Billing helpers"
$ cp ../../billing_v2.py billing.py
$ git commit -q -am "Move receipt below tax and align its columns"
# The same two snapshots under four algorithms: lines added, lines deleted
$ for algo in myers minimal patience histogram; do printf '%-10s' $algo; git diff --numstat --diff-algorithm=$algo HEAD~1 HEAD | cut -f1,2; done
myers     7	7
minimal   7	7
patience  8	8
histogram 8	8
$ git diff --diff-algorithm=myers HEAD~1 HEAD
diff --git a/billing.py b/billing.py
index 76d8fcd..e7d2b4f 100644
--- a/billing.py
+++ b/billing.py
@@ -12,19 +12,19 @@ def total(order):
     return amount
 
 
+def tax(order):
+    amount = total(order)
+    return round(amount * 0.05)
+
+
 def receipt(order):
     lines = []
     for item in order:
-        lines.append(f"{item}: {price(item)}")
-    lines.append(f"Total: {total(order)}")
+        lines.append(f"{item:<6}{price(item):>4}")
+    lines.append(f"{'Total':<6}{total(order):>4}")
     return "\n".join(lines)
 
 
-def tax(order):
-    amount = total(order)
-    return round(amount * 0.05)
-
-
 def count(order):
     amount = 0
     for item in order:
$ git diff --diff-algorithm=histogram HEAD~1 HEAD
diff --git a/billing.py b/billing.py
index 76d8fcd..e7d2b4f 100644
--- a/billing.py
+++ b/billing.py
@@ -12,19 +12,19 @@ def total(order):
     return amount
 
 
-def receipt(order):
-    lines = []
-    for item in order:
-        lines.append(f"{item}: {price(item)}")
-    lines.append(f"Total: {total(order)}")
-    return "\n".join(lines)
-
-
 def tax(order):
     amount = total(order)
     return round(amount * 0.05)
 
 
+def receipt(order):
+    lines = []
+    for item in order:
+        lines.append(f"{item:<6}{price(item):>4}")
+    lines.append(f"{'Total':<6}{total(order):>4}")
+    return "\n".join(lines)
+
+
 def count(order):
     amount = 0
     for item in order:
# --anchored keeps a line that occurs once on each side from appearing as deleted and added
$ git diff --numstat --anchored='def tax(order):' HEAD~1 HEAD
8	8	billing.py
# --color-moved gives moved lines colours of their own; show_colors.py names them in words
$ git diff --color=always --color-moved=plain HEAD~1 HEAD | python3 ../../show_colors.py
            diff --git a/billing.py b/billing.py
            index 76d8fcd..e7d2b4f 100644
            --- a/billing.py
            +++ b/billing.py
            @@ -12,19 +12,19 @@ def total(order):
                 return amount
             
             
moved to:   +def tax(order):
moved to:   +    amount = total(order)
moved to:   +    return round(amount * 0.05)
moved to:   +
moved to:   +
             def receipt(order):
                 lines = []
                 for item in order:
deleted:    -        lines.append(f"{item}: {price(item)}")
deleted:    -    lines.append(f"Total: {total(order)}")
added:      +        lines.append(f"{item:<6}{price(item):>4}")
added:      +    lines.append(f"{'Total':<6}{total(order):>4}")
                 return "\n".join(lines)
             
             
moved from: -def tax(order):
moved from: -    amount = total(order)
moved from: -    return round(amount * 0.05)
moved from: -
moved from: -
             def count(order):
                 amount = 0
                 for item in order:

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 algorithms.py

Myers and minimal deleted 7 lines and added 7, 14 in all; patience and histogram needed 16. Both diffs are correct, and they tell different stories. Myers left receipt() where it was, with its two edited lines as the only change inside it, and reported that tax() moved up: five lines, counting its two blank lines. Histogram reported what the programmer actually did: all of receipt() moved down, edits included.

  • Myers is the default. It finds a shortest edit script, the fewest deleted plus added lines, after Eugene Myers’s paper “An O(ND) Difference Algorithm and its Variations”. On very large inputs Git cuts the search short with heuristics and accepts a result that may not be the shortest.
  • minimal is Myers without those shortcuts: it spends extra time to guarantee the smallest diff. Git 2.50 fixed a case in which --minimal could still give a non-minimal result.
  • patience first matches the lines that occur exactly once in both versions, such as def tax(order):, keeps the longest run of such pairs that appear in the same order in both files, and then works recursively between them, falling back to Myers where no unique lines are left.
  • histogram extends patience to lines that are rare but not unique. It is the algorithm that the ort merge strategy uses by default, which matters in the next sections.

Which to use? Myers is fine for most edits and is what git diff uses unless told otherwise. Minimal is for the rare case where the size of the diff really matters, such as a patch you will edit by hand, and it can be slow on large files. Patience and histogram suit code, where shared lines such as braces and blank lines can mislead Myers; histogram is a common choice for reviews, and ort already merges with it.

The last two commands of the session show tools for steering the result. --anchored='def tax(order):' asks Git never to show that line as deleted and added, which gave the histogram-like 16-line diff. And --color-moved colours lines that moved differently from lines that changed; show_colors.py names the colours in words. With it, even the Myers diff reads correctly: the five lines of tax() are marked as moved, and only the two edited lines of receipt() stand out as real changes.

To make an algorithm your default, set diff.algorithm, for example to histogram; since Git 2.53, git blame also accepts --diff-algorithm, so that it matches lines the way your diffs do.

Text Diff Paste both versions of billing.py and compare the line view with Git's hunks: a diff tool chooses one description of many.

Renames are found, not stored

Because commits record snapshots, Git finds a rename by comparing each deleted path with each added one and measuring how similar their contents are:

Rename and copy detection at the threshold Python · renames.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 20\nchai 10\ndosa 40\nupma 25\npoha 20\nsambar 15\nchutney 5\n' > menu.txt
git add .
git commit -q -m "Base menu"
# Rename the file and change one line of eight
git mv menu.txt breakfast.txt
printf 'idli 30\nvada 20\nchai 12\ndosa 40\nupma 25\npoha 20\nsambar 15\nchutney 5\n' > breakfast.txt
git add -A
git diff --staged --name-status
git diff --staged | head -n 4
# Above the threshold it is a rename; demand 90 % and it becomes a deletion and an addition
git diff --staged -M90% --name-status
git diff --staged --no-renames --name-status
git commit -q -m "Rename and edit"
git log --oneline --name-status --follow -- breakfast.txt
# Copies are found only when asked, and an unchanged source only with --find-copies-harder
cp breakfast.txt lunch.txt
git add lunch.txt
git diff --staged --name-status -C
git diff --staged --name-status -C --find-copies-harder
git commit -q -m "Copy for lunch"
# Rename detection compares every deleted file with every added one, so Git caps the work
for i in 1 2 3; do printf "item $i\nprice $i\nnote $i\n" > a$i.txt; done
git add . && git commit -q -m "Three files"
for i in 1 2 3; do git mv a$i.txt b$i.txt; printf "extra\n" >> b$i.txt; done
git add -A
git diff --staged --name-status -l1
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 20\nchai 10\ndosa 40\nupma 25\npoha 20\nsambar 15\nchutney 5\n' > menu.txt
$ git add .
$ git commit -q -m "Base menu"
# Rename the file and change one line of eight
$ git mv menu.txt breakfast.txt
$ printf 'idli 30\nvada 20\nchai 12\ndosa 40\nupma 25\npoha 20\nsambar 15\nchutney 5\n' > breakfast.txt
$ git add -A
$ git diff --staged --name-status
R088	menu.txt	breakfast.txt
$ git diff --staged | head -n 4
diff --git a/menu.txt b/breakfast.txt
similarity index 88%
rename from menu.txt
rename to breakfast.txt
# Above the threshold it is a rename; demand 90 % and it becomes a deletion and an addition
$ git diff --staged -M90% --name-status
A	breakfast.txt
D	menu.txt
$ git diff --staged --no-renames --name-status
A	breakfast.txt
D	menu.txt
$ git commit -q -m "Rename and edit"
$ git log --oneline --name-status --follow -- breakfast.txt
2a5d9ec Rename and edit
R088	menu.txt	breakfast.txt
43a53fb Base menu
A	menu.txt
# Copies are found only when asked, and an unchanged source only with --find-copies-harder
$ cp breakfast.txt lunch.txt
$ git add lunch.txt
$ git diff --staged --name-status -C
A	lunch.txt
$ git diff --staged --name-status -C --find-copies-harder
C100	breakfast.txt	lunch.txt
$ git commit -q -m "Copy for lunch"
# Rename detection compares every deleted file with every added one, so Git caps the work
$ for i in 1 2 3; do printf "item $i\nprice $i\nnote $i\n" > a$i.txt; done
$ git add . && git commit -q -m "Three files"
$ for i in 1 2 3; do git mv a$i.txt b$i.txt; printf "extra\n" >> b$i.txt; done
$ git add -A
$ git diff --staged --name-status -l1
D	a1.txt
D	a2.txt
D	a3.txt
A	b1.txt
A	b2.txt
A	b3.txt
warning: exhaustive rename detection was skipped due to too many files.
warning: you may want to set your diff.renameLimit variable to at least 3 and retry the command.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 renames.py

menu.txt became breakfast.txt with one of its eight lines changed, and --name-status printed R088: a rename with a similarity index of 88 %. The similarity index measures how much of the file is unchanged, and the default threshold is 50 %. With -M90% the same pair fell below the threshold and became D and A, which is also what --no-renames shows. git log --follow uses the same detection to keep a file’s history across a rename.

Copies are found only when you ask. -C looks for copy sources among the files modified in the same change, so a copy of an unchanged file stayed a plain A; --find-copies-harder also considers unmodified files and found C100, a copy with 100 % similarity, at a cost that grows with the size of the project.

The last step shows the limit. Comparing every deleted path with every added path takes time proportional to their product, so Git caps the exhaustive search: diff.renameLimit defaults to 1,000 files, and merges use merge.renameLimit, 7,000 unless set. With -l1, three renamed files were reported as three deletions and three additions, and Git printed the limit you would need. A refactor that moves thousands of files is exactly where the limit bites, so raise it for that diff when the renames matter.

The three-way merge, region by region

A merge of two branches compares three versions of each file: the merge base and the two tips. A file that only one side changed is taken from that side whole. A file that both sides changed is merged region by region, and git merge-file runs that same file merge on three plain files. Before reading the output, predict each result:

Five small merges to predict Python · hunks.py
from gitsession import session

session(r"""
mkdir merges
cd merges
printf 'idli 30\nvada 20\nchai 10\n' > base.txt
# 1. Two changes with an unchanged line between them: merged cleanly
printf 'idli 35\nvada 20\nchai 10\n' > ours.txt
printf 'idli 30\nvada 20\nchai 15\n' > theirs.txt
git merge-file -p ours.txt base.txt theirs.txt
# 2. Changes to neighbouring lines conflict, although no line was changed twice
printf 'idli 30\nvada 25\nchai 10\n' > theirs.txt
! git merge-file -p -L ours -L base -L theirs ours.txt base.txt theirs.txt
# The diff3 style adds the base's version between the two sides
! git merge-file -p --diff3 -L ours -L base -L theirs ours.txt base.txt theirs.txt
# 3. Both sides made the same change: merged cleanly
cp ours.txt theirs.txt
git merge-file -p ours.txt base.txt theirs.txt
# 4. Both sides added a different last line: a conflict
printf 'idli 30\nvada 20\nchai 10\ndosa 40\n' > ours.txt
printf 'idli 30\nvada 20\nchai 10\nupma 25\n' > theirs.txt
! git merge-file -p -L ours -L base -L theirs ours.txt base.txt theirs.txt
# 5. Ours raised idli to 35 and then reverted it; theirs raised it to 35. Only the end states count
cp base.txt ours.txt
printf 'idli 35\nvada 20\nchai 10\n' > theirs.txt
git merge-file -p ours.txt base.txt theirs.txt
""")

Output

$ mkdir merges
$ cd merges
$ printf 'idli 30\nvada 20\nchai 10\n' > base.txt
# 1. Two changes with an unchanged line between them: merged cleanly
$ printf 'idli 35\nvada 20\nchai 10\n' > ours.txt
$ printf 'idli 30\nvada 20\nchai 15\n' > theirs.txt
$ git merge-file -p ours.txt base.txt theirs.txt
idli 35
vada 20
chai 15
# 2. Changes to neighbouring lines conflict, although no line was changed twice
$ printf 'idli 30\nvada 25\nchai 10\n' > theirs.txt
$ git merge-file -p -L ours -L base -L theirs ours.txt base.txt theirs.txt
<<<<<<< ours
idli 35
vada 20
=======
idli 30
vada 25
>>>>>>> theirs
chai 10
[exit status 1]
# The diff3 style adds the base's version between the two sides
$ git merge-file -p --diff3 -L ours -L base -L theirs ours.txt base.txt theirs.txt
<<<<<<< ours
idli 35
vada 20
||||||| base
idli 30
vada 20
=======
idli 30
vada 25
>>>>>>> theirs
chai 10
[exit status 1]
# 3. Both sides made the same change: merged cleanly
$ cp ours.txt theirs.txt
$ git merge-file -p ours.txt base.txt theirs.txt
idli 35
vada 20
chai 10
# 4. Both sides added a different last line: a conflict
$ printf 'idli 30\nvada 20\nchai 10\ndosa 40\n' > ours.txt
$ printf 'idli 30\nvada 20\nchai 10\nupma 25\n' > theirs.txt
$ git merge-file -p -L ours -L base -L theirs ours.txt base.txt theirs.txt
idli 30
vada 20
chai 10
<<<<<<< ours
dosa 40
=======
upma 25
>>>>>>> theirs
[exit status 1]
# 5. Ours raised idli to 35 and then reverted it; theirs raised it to 35. Only the end states count
$ cp base.txt ours.txt
$ printf 'idli 35\nvada 20\nchai 10\n' > theirs.txt
$ git merge-file -p ours.txt base.txt theirs.txt
idli 35
vada 20
chai 10

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hunks.py

  1. idli changed on our side and chai on theirs, with the unchanged vada line between them, so both changes arrived.
  2. idli changed on our side and vada on theirs. No line was changed by both, yet this is a conflict: with no unchanged line between them, the two changes form one region, and Git will not guess how they combine. The diff3 style, which merge.conflictStyle = diff3 makes the default, adds the base’s version of the region, which makes such conflicts much easier to resolve. zdiff3 does the same, and it also takes lines that both sides share at the start or the end of a conflict out of the conflicting region.
  3. Both sides made the same change, so there is nothing to choose.
  4. Both sides added a different line at the end, and an addition at the same place on both sides conflicts.
  5. Ours had raised idli to 35 and reverted it; theirs raised it to 35. The merge compares only the end states with the base, so ours looks unchanged and theirs wins: the reverted change comes back without a conflict. The Git documentation warns about exactly this. If you meant to keep the old value, check the merged file and change it again after the merge.

git merge-file exits with the number of conflicts, which is why the conflicting cases end with exit status 1.

Criss-cross histories and the virtual merge base

Usually two branches have one best common ancestor. When each branch has merged the other, they can have two, and choosing between them is not safe:

Two merge bases, two clean answers, one honest conflict Python · crisscross.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git config set --global merge.conflictStyle diff3
git init -q -b main canteen
cd canteen
printf 'open 8\nidli 30\nclose 22\n' > menu.txt
git add menu.txt
git commit -q -m "Base"
git branch feature
printf 'open 8\nidli 32\nclose 22\n' > menu.txt
git commit -q -am "Idli 32"
git switch -q feature
printf 'open 8\nidli 35\nclose 22\n' > menu.txt
git commit -q -am "Idli 35"
# Each branch merges the other's commit and settles the conflict its own way
git switch -q main
! git merge -q feature
printf 'open 8\nidli 32\nclose 22\n' > menu.txt
git commit -q -am "Merge feature, keep 32"
git switch -q feature
! git merge -q main~1
printf 'open 8\nidli 35\nclose 22\n' > menu.txt
git commit -q -am "Merge main, keep 35"
git log --oneline --graph --all
# Two commits are equally good merge bases now
git merge-base --all main feature
# With either base alone the merge would look clean, and the two give opposite answers
git switch -q main
git show main:menu.txt > ../ours.txt
git show feature:menu.txt > ../theirs.txt
git show aba782f:menu.txt > ../base-a.txt
git show 2f1c5fb:menu.txt > ../base-b.txt
git merge-file -p ../ours.txt ../base-a.txt ../theirs.txt
git merge-file -p ../ours.txt ../base-b.txt ../theirs.txt
# ort first merges the two bases; their own conflict appears inside the merged common ancestors
! git merge feature
cat menu.txt
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git config set --global merge.conflictStyle diff3
$ git init -q -b main canteen
$ cd canteen
$ printf 'open 8\nidli 30\nclose 22\n' > menu.txt
$ git add menu.txt
$ git commit -q -m "Base"
$ git branch feature
$ printf 'open 8\nidli 32\nclose 22\n' > menu.txt
$ git commit -q -am "Idli 32"
$ git switch -q feature
$ printf 'open 8\nidli 35\nclose 22\n' > menu.txt
$ git commit -q -am "Idli 35"
# Each branch merges the other's commit and settles the conflict its own way
$ git switch -q main
$ git merge -q feature
Auto-merging menu.txt
CONFLICT (content): Merge conflict in menu.txt
Automatic merge failed; fix conflicts and then commit the result.
[exit status 1]
$ printf 'open 8\nidli 32\nclose 22\n' > menu.txt
$ git commit -q -am "Merge feature, keep 32"
$ git switch -q feature
$ git merge -q main~1
Auto-merging menu.txt
CONFLICT (content): Merge conflict in menu.txt
Automatic merge failed; fix conflicts and then commit the result.
[exit status 1]
$ printf 'open 8\nidli 35\nclose 22\n' > menu.txt
$ git commit -q -am "Merge main, keep 35"
$ git log --oneline --graph --all
*   c7651d7 Merge main, keep 35
|\  
| | * c1641f4 Merge feature, keep 32
| |/| 
| |/  
|/|   
* | 2f1c5fb Idli 35
| * aba782f Idli 32
|/  
* 6a1b727 Base
# Two commits are equally good merge bases now
$ git merge-base --all main feature
aba782fd50c8227c3734edf167b25451869a2a1c
2f1c5fb55c5cfc7d6d4090d03375e608565a54dd
# With either base alone the merge would look clean, and the two give opposite answers
$ git switch -q main
$ git show main:menu.txt > ../ours.txt
$ git show feature:menu.txt > ../theirs.txt
$ git show aba782f:menu.txt > ../base-a.txt
$ git show 2f1c5fb:menu.txt > ../base-b.txt
$ git merge-file -p ../ours.txt ../base-a.txt ../theirs.txt
open 8
idli 35
close 22
$ git merge-file -p ../ours.txt ../base-b.txt ../theirs.txt
open 8
idli 32
close 22
# ort first merges the two bases; their own conflict appears inside the merged common ancestors
$ git merge feature
Auto-merging menu.txt
CONFLICT (content): Merge conflict in menu.txt
Automatic merge failed; fix conflicts and then commit the result.
[exit status 1]
$ cat menu.txt
open 8
<<<<<<< HEAD
idli 32
||||||| merged common ancestors
<<<<<<<<< Temporary merge branch 1
idli 35
||||||||| 6a1b727
idli 30
=========
idli 32
>>>>>>>>> Temporary merge branch 2
=======
idli 35
>>>>>>> feature
close 22

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 crisscross.py

main raised idli to 32 and feature to 35, and then each merged the other’s commit and kept its own price. Both of those commits, aba782f and 2f1c5fb, are now common ancestors of the two tips, and neither is an ancestor of the other, so git merge-base --all printed both.

Two merges cross: each branch merged the other, so the branch tips share two merge bases, one with idli 32 and one with idli 35.6a1b727 Baseidli 30aba782f Idli 32(merge base 1)2f1c5fb Idli 35(merge base 2)c1641f4 on mainmerged, kept 32c7651d7 on featuremerged, kept 35parentparent

A criss-cross history with two merge bases

Text description of the diagram

The diagram shows the history of the criss-cross session, oldest commit at the bottom.

  1. 6a1b727, "Base", has idli at 30.
  2. Two commits were made on top of it: aba782f, "Idli 32", on main, and 2f1c5fb, "Idli 35", on feature. Both are drawn with double borders because they become the merge bases.
  3. c1641f4 on main merged feature's commit and kept 32. Its parents are aba782f and 2f1c5fb.
  4. c7651d7 on feature merged main's first commit and kept 35. Its parents are 2f1c5fb and aba782f.

Both aba782f and 2f1c5fb are reachable from both tips, and neither is reachable from the other, so git merge-base --all prints both: there is no single best common ancestor.

The two git merge-file lines show the danger. With aba782f (32) as the base, our side looks unchanged and theirs looks changed, so the merge quietly picks 35. With 2f1c5fb (35) as the base, it quietly picks 32. Either choice gives a clean merge and a wrong one, because the two branches really disagree. The ort strategy, Git’s default since 2.34, does neither: it first merges the merge bases into a virtual merge base, using their own common ancestor 6a1b727, and merges the tips against that. The bases’ own disagreement became a conflict inside the virtual base, which the diff3 style shows nested under merged common ancestors, and the final merge reported a conflict for you to settle. It is the only honest answer.

Preview a merge without a working tree

A server that shows whether a pull request can merge has no working tree to merge in. git merge-tree --write-tree, added in Git 2.38, performs the whole merge in memory and writes only objects:

A merge computed, committed and checked without a checkout Python · mergetree.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
git add menu.txt
git commit -q -m "Base menu"
git branch late
git switch -q -c feature
printf 'idli 30\nvada 20\nchai 15\n' > menu.txt
git commit -q -am "Chai 15"
git switch -q main
printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
git commit -q -am "Idli 35"
# The merge is computed without touching the index or the files; the output is the merged tree's ID
git merge-tree --write-tree main feature
git cat-file -p aa709450422b9957acb8bca9f62337a3cc0cbdb2:menu.txt
git merge-tree --quiet main feature; echo "exit status $?"
git status --short
# Turn the tree into a merge commit and move main, still without a working tree
git commit-tree aa70945 -p main -p feature -m "Merge feature"
git update-ref refs/heads/main e5b0be580cc5c02f65d93999b3085e1eea96902e f41cd78d3bb58b06c4e15dd80891d8f9bb16e35e
git log --oneline --graph
# The files and the index still hold the old main; a server has no working tree, and here reset catches up
git status --short
git reset -q --hard
# A merge that conflicts: exit status 1, the conflicted entries with their stages, then the messages
git switch -q late
printf 'idli 30\nvada 22\nchai 10\n' > menu.txt
git commit -q -am "Vada 22"
! git merge-tree --write-tree main late
! git merge-tree --write-tree --name-only main late
# The tree it wrote holds the file with conflict markers, ready for someone to resolve
git cat-file -p 744ab29830f286bac96a81ce41473f4d1e189c7a:menu.txt
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
$ git add menu.txt
$ git commit -q -m "Base menu"
$ git branch late
$ git switch -q -c feature
$ printf 'idli 30\nvada 20\nchai 15\n' > menu.txt
$ git commit -q -am "Chai 15"
$ git switch -q main
$ printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
$ git commit -q -am "Idli 35"
# The merge is computed without touching the index or the files; the output is the merged tree's ID
$ git merge-tree --write-tree main feature
aa709450422b9957acb8bca9f62337a3cc0cbdb2
$ git cat-file -p aa709450422b9957acb8bca9f62337a3cc0cbdb2:menu.txt
idli 35
vada 20
chai 15
$ git merge-tree --quiet main feature; echo "exit status $?"
exit status 0
$ git status --short
# Turn the tree into a merge commit and move main, still without a working tree
$ git commit-tree aa70945 -p main -p feature -m "Merge feature"
e5b0be580cc5c02f65d93999b3085e1eea96902e
$ git update-ref refs/heads/main e5b0be580cc5c02f65d93999b3085e1eea96902e f41cd78d3bb58b06c4e15dd80891d8f9bb16e35e
$ git log --oneline --graph
*   e5b0be5 Merge feature
|\  
| * 1144951 Chai 15
* | f41cd78 Idli 35
|/  
* f6cbbc9 Base menu
# The files and the index still hold the old main; a server has no working tree, and here reset catches up
$ git status --short
M  menu.txt
$ git reset -q --hard
# A merge that conflicts: exit status 1, the conflicted entries with their stages, then the messages
$ git switch -q late
$ printf 'idli 30\nvada 22\nchai 10\n' > menu.txt
$ git commit -q -am "Vada 22"
$ git merge-tree --write-tree main late
744ab29830f286bac96a81ce41473f4d1e189c7a
100644 0cc7caaf7dc0c437bcc54bd321d4ce98e69741a5 1	menu.txt
100644 c6fd34a3244626f8bd83bca20f3a603378df0cce 2	menu.txt
100644 594d9cb645b62d6fa51462dc1168bcc1d3f4a99b 3	menu.txt

Auto-merging menu.txt
CONFLICT (content): Merge conflict in menu.txt
[exit status 1]
$ git merge-tree --write-tree --name-only main late
744ab29830f286bac96a81ce41473f4d1e189c7a
menu.txt

Auto-merging menu.txt
CONFLICT (content): Merge conflict in menu.txt
[exit status 1]
# The tree it wrote holds the file with conflict markers, ready for someone to resolve
$ git cat-file -p 744ab29830f286bac96a81ce41473f4d1e189c7a:menu.txt
<<<<<<< main
idli 35
vada 20
chai 15
=======
idli 30
vada 22
chai 10
>>>>>>> late

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mergetree.py

For the clean merge, the output was one line, the ID of the merged tree, and git cat-file showed its menu.txt with both changes. --quiet, added in Git 2.50, prints nothing and only sets the exit status, 0 for clean, and it may stop early and skip writing most objects. git status showed that the files and the index were untouched. git commit-tree then made a merge commit with both parents from that tree, and git update-ref moved main, the same sequence the git merge-tree documentation gives. The checkout then lagged behind, which a server with a bare repository never has to worry about.

The conflicting merge exited with status 1 and printed three sections: the tree, the conflicted entries with their stage numbers (1 base, 2 ours, 3 theirs, as in the index), and the messages that git merge would have printed. The tree it wrote holds menu.txt with conflict markers. The documentation’s advice is to rely on the exit status and the conflicted-file list, never on searching that tree for markers, because some conflicts, such as one side deleting a file the other changed, leave no markers at all.

Find the hand-made part of a merge

A merge commit can contain changes that the automatic merge did not make, sometimes called an evil merge: a fix made while resolving conflicts, or a change dropped by mistake. This session makes one of each:

A merge that hid a dropped change Python · remerge.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
printf 'open 8\nclose 22\n' > hours.txt
git add .
git commit -q -m "Base"
git switch -q -c feature
printf 'idli 30\nvada 20\nchai 15\n' > menu.txt
git commit -q -am "Chai 15"
git switch -q main
printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
git commit -q -am "Idli 35"
# The merge itself is clean, but before committing, someone also changes the opening hour
git merge -q --no-commit feature
printf 'open 7\nclose 22\n' > hours.txt
git commit -q -am "Merge feature"
# The usual view of a merge shows every file that differs from both parents
git show --format='%h %s' HEAD
# --remerge-diff merges the parents again and shows only what differs from that automatic result
git show --format='%h %s' --remerge-diff HEAD
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
$ printf 'open 8\nclose 22\n' > hours.txt
$ git add .
$ git commit -q -m "Base"
$ git switch -q -c feature
$ printf 'idli 30\nvada 20\nchai 15\n' > menu.txt
$ git commit -q -am "Chai 15"
$ git switch -q main
$ printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
$ git commit -q -am "Idli 35"
# The merge itself is clean, but before committing, someone also changes the opening hour
$ git merge -q --no-commit feature
Auto-merging menu.txt
Automatic merge went well; stopped before committing as requested
$ printf 'open 7\nclose 22\n' > hours.txt
$ git commit -q -am "Merge feature"
# The usual view of a merge shows every file that differs from both parents
$ git show --format='%h %s' HEAD
a27df26 Merge feature

diff --cc hours.txt
index 1b2619f,1b2619f..ec21b45
--- a/hours.txt
+++ b/hours.txt
@@@ -1,2 -1,2 +1,2 @@@
--open 8
++open 7
  close 22
diff --cc menu.txt
index 14a6fde,065cff3..c6fd34a
--- a/menu.txt
+++ b/menu.txt
@@@ -1,3 -1,3 +1,3 @@@
 -idli 30
 +idli 35
  vada 20
- chai 10
+ chai 15
# --remerge-diff merges the parents again and shows only what differs from that automatic result
$ git show --format='%h %s' --remerge-diff HEAD
a27df26 Merge feature

diff --git a/hours.txt b/hours.txt
index 1b2619f..ec21b45 100644
--- a/hours.txt
+++ b/hours.txt
@@ -1,2 +1,2 @@
-open 8
+open 7
 close 22

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 remerge.py

The automatic merge produced chai 15, but the person committing it put chai 10 back and also changed the opening hour. The usual view of a merge, the dense combined diff of git show, lists only files that differ from every parent. menu.txt now matches the first parent exactly, so it is not shown at all, and feature’s change has vanished without a trace. --remerge-diff, added in Git 2.36, merges the parents again and diffs that automatic result against the commit: it showed the edited hour and the dropped chai 15. Use it on git log or git show whenever a bug appears in code that neither branch changed.

Strategy options: -X ours is not -s ours

Two spellings that look alike do very different things:

-X ours keeps their other changes; -s ours keeps nothing Python · strategies.py
from gitsession import session

session(r"""
git config set --global user.name "Ravi Kumar"
git config set --global user.email "ravi@example.com"
git init -q -b main canteen
cd canteen
printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
printf 'open 8\nclose 22\n' > hours.txt
git add .
git commit -q -m "Base"
git switch -q -c feature
printf 'idli 32\nvada 20\nchai 15\n' > menu.txt
printf 'open 7\nclose 22\n' > hours.txt
git commit -q -am "Feature prices and hours"
git switch -q main
printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
git commit -q -am "Idli 35"
git branch before
# -X ours: only the conflicting idli line is settled in our favour; chai 15 and open 7 still arrive
git merge -q -X ours feature -m "Merge with -X ours"
git diff --stat before HEAD
cat menu.txt hours.txt
# -s ours: the result is our tree exactly, and feature's work is recorded as merged but thrown away
git reset -q --hard before
git merge -q -s ours feature -m "Merge with -s ours"
git diff --stat before HEAD
cat menu.txt hours.txt
git log --oneline --graph -3
""")

Output

$ git config set --global user.name "Ravi Kumar"
$ git config set --global user.email "ravi@example.com"
$ git init -q -b main canteen
$ cd canteen
$ printf 'idli 30\nvada 20\nchai 10\n' > menu.txt
$ printf 'open 8\nclose 22\n' > hours.txt
$ git add .
$ git commit -q -m "Base"
$ git switch -q -c feature
$ printf 'idli 32\nvada 20\nchai 15\n' > menu.txt
$ printf 'open 7\nclose 22\n' > hours.txt
$ git commit -q -am "Feature prices and hours"
$ git switch -q main
$ printf 'idli 35\nvada 20\nchai 10\n' > menu.txt
$ git commit -q -am "Idli 35"
$ git branch before
# -X ours: only the conflicting idli line is settled in our favour; chai 15 and open 7 still arrive
$ git merge -q -X ours feature -m "Merge with -X ours"
Auto-merging menu.txt
$ git diff --stat before HEAD
 hours.txt | 2 +-
 menu.txt  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
$ cat menu.txt hours.txt
idli 35
vada 20
chai 15
open 7
close 22
# -s ours: the result is our tree exactly, and feature's work is recorded as merged but thrown away
$ git reset -q --hard before
$ git merge -q -s ours feature -m "Merge with -s ours"
$ git diff --stat before HEAD
$ cat menu.txt hours.txt
idli 35
vada 20
chai 10
open 8
close 22
$ git log --oneline --graph -3
*   d480fce Merge with -s ours
|\  
| * c45598f Feature prices and hours
* | b17dd0e Idli 35
|/  

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strategies.py

-X ours is an option of the ort strategy: in a conflicting region it takes our side, and every change of theirs that does not conflict still arrives, here chai 15 and open 7. -s ours is a different strategy: the result is our tree exactly, git diff --stat shows no change at all, and yet the commit has two parents, so Git records feature’s work as merged while throwing it away. It exists for retiring an old branch, and it is almost never what someone looking for “keep my version” wants. -X theirs mirrors -X ours; there is no -s theirs.

Git Patch and Diff Viewer (Apply, Reverse, Split) Open a patch saved with git show --remerge-diff > merge.patch and read each file's hunks before you accept a merge.

Practise: count the lines of a shortest diff

A shortest diff keeps the longest sequence of lines that both versions share in order and deletes or adds everything else. Count its size, and check yourself against the numbers Git printed, including the 14 lines of the billing change.

Exercise · Medium · Python

Count the lines of a shortest diff

A diff is a list of lines to delete and lines to add, and the shortest possible one is what git diff --minimal looks for. Write edit_size(old, new) that returns its size: the number of lines it deletes plus the number it adds, which is what the two numbers of git diff --minimal --numstat add up to.

old and new are lists of lines, without newlines. Every expected number in the tests was counted by Git 2.54 on the same lines, including the billing change of the lesson, where --minimal and Myers needed 14 lines and histogram 16. For example, changing the middle line of three gives 2: one line deleted, one added.

Starter code · edit_size.py

def edit_size(old, new):
    """Return how many lines a shortest diff from old to new deletes plus adds.

    old and new are lists of lines without their newlines, such as ["idli 30", "vada 20"].
    """
    # Replace this line with your code.
    return -1
The sample tests · test_edit_size.py
from edit_size import edit_size

# Each expected number is the sum of the two numbers that git diff --minimal --numstat printed (Git 2.54).

MENU = ['MENU = {"idli": 30, "vada": 20, "chai": 10}']
PRICE = ["def price(item):", "    return MENU[item]"]
TOTAL = ["def total(order):", "    amount = 0", "    for item in order:", "        amount += price(item)", "    return amount"]
TAX = ["def tax(order):", "    amount = total(order)", "    return round(amount * 0.05)"]
COUNT = ["def count(order):", "    amount = 0", "    for item in order:", "        amount += 1", "    return amount"]
RECEIPT_OLD = [
    "def receipt(order):",
    "    lines = []",
    "    for item in order:",
    '        lines.append(f"{item}: {price(item)}")',
    '    lines.append(f"Total: {total(order)}")',
    '    return "\\n".join(lines)',
]
RECEIPT_NEW = [
    "def receipt(order):",
    "    lines = []",
    "    for item in order:",
    '        lines.append(f"{item:<6}{price(item):>4}")',
    "    lines.append(f\"{'Total':<6}{total(order):>4}\")",
    '    return "\\n".join(lines)',
]


def billing(*functions):
    """billing.py of the lesson: the menu, then each function after two blank lines."""
    lines = list(MENU)
    for function in functions:
        lines += ["", ""] + function
    return lines


def test_same():
    """counts nothing for two equal versions"""
    assert edit_size(["idli 30", "vada 20"], ["idli 30", "vada 20"]) == 0


def test_one_line_changed():
    """counts a changed line as one deletion and one addition"""
    assert edit_size(["idli 30", "vada 20", "chai 10"], ["idli 30", "vada 25", "chai 10"]) == 2


def test_from_nothing():
    """counts every line of a new file"""
    assert edit_size([], ["idli 30", "vada 20", "chai 10"]) == 3


def test_edit_and_append():
    """handles a change and an added last line together"""
    assert edit_size(["idli 30", "vada 20", "chai 10", "dosa 40"], ["idli 30", "vada 25", "chai 10", "dosa 40", "upma 25"]) == 3


def test_repeated_lines():
    """keeps the longest run of shared lines when lines repeat"""
    assert edit_size(["x", "y", "x", "y", "x"], ["y", "x", "y", "x", "y"]) == 2


def test_swapped_functions():
    """moves the shorter of two swapped blocks"""
    old = ["def tax():", "    return 5", "", "def tip():", "    return 10", "", "def total():", "    return 15"]
    new = ["def tip():", "    return 10", "", "def tax():", "    return 5", "", "def total():", "    return 15"]
    assert edit_size(old, new) == 6


def test_billing_change():
    """finds the 14 lines that git diff --minimal needed for the billing change"""
    old = billing(PRICE, TOTAL, RECEIPT_OLD, TAX, COUNT)
    new = billing(PRICE, TOTAL, TAX, RECEIPT_NEW, COUNT)
    assert edit_size(old, new) == 14
A hint

Every line that a shortest diff keeps belongs to the longest sequence of lines that both versions share in the same order, the longest common subsequence. Everything else is deleted from old or added from new, so the answer is len(old) + len(new) - 2 * common. A table of the common length for every pair of prefixes, filled row by row, finds common.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 The base has "idli 30", "vada 20" and "chai 10" on three lines. Ours changes the first line to "idli 35" and theirs changes the second to "vada 25". What does the three-way merge do?

    Choose one answer.

    Show the answer to question 1

    Answer: It reports a conflict, because the two changes touch neighbouring lines

    Git merges region by region, and two changed regions with no unchanged line between them form one conflicting region, as case 2 of the hunks session showed. Changes with at least one unchanged line between them, as in case 1, merge cleanly.

  2. Question 2 of 6 Both branches raised idli from 30 to 35. Later, main reverted its change back to 30. You merge the other branch into main. What does menu.txt say?

    Choose one answer.

    Show the answer to question 2

    Answer: idli 35, because only main's current state, the other tip and the merge base are compared

    The merge base still says 30, main says 30 again, and the other branch says 35, so only the other side seems to have changed the line, and its change wins without a conflict. The individual commits, including the revert, are not looked at, as case 5 of the hunks session showed.

  3. Question 3 of 6 What does the default Myers algorithm try to keep small?

    Choose one answer.

    Show the answer to question 3

    Answer: The number of lines deleted plus the number added

    Myers looks for a shortest edit script, the fewest deletions and additions that turn one version into the other. On the billing change it found 14 lines by treating tax() as moved; patience and histogram anchor on lines that are rare in both files and reported receipt() as moved, 16 lines. Git also adds heuristics that can cut the search short on very large inputs, which --minimal turns off.

  4. Question 4 of 6 git diff --name-status shows R088 for menu.txt renamed to breakfast.txt. What does git diff -M90% --name-status show for the same change?

    Choose one answer.

    Show the answer to question 4

    Answer: D for menu.txt and A for breakfast.txt

    Git stores no renames: it pairs a deleted and an added path when they are similar enough. 88 % is below a required 90 %, so the pair is reported as a deletion and an addition, as the renames session showed.

  5. Question 5 of 6 In the criss-cross session, how many commits did git merge-base --all print?

    Type a number.

    Show the answer to question 5

    Answer: 2 commits

    Each branch had merged the other's commit, so aba782f and 2f1c5fb were both common ancestors and neither was an ancestor of the other. ort merged those two into a virtual merge base before merging the tips.

  6. Question 6 of 6 You merge feature with -X ours. Feature changed the idli line, which you also changed, and the chai line and hours.txt, which you did not touch. What does the result keep from feature?

    Choose every answer that is right.

    Show the answer to question 6

    Answer:

    • The change to hours.txt
    • The chai line

    -X ours settles only the conflicting hunk, the idli line, in your favour; every change of feature that does not conflict still arrives. The strategy -s ours is the one that keeps nothing from the other branch.

Run the sessions yourself

To repeat a session, place gitsession.py beside it; the algorithms session also needs billing_v1.py, billing_v2.py and show_colors.py. Then run, say, python3 hunks.py with Python 3 and Git installed, on macOS or Linux. The helper creates a throwaway home folder called learner, runs the session’s commands there one after another, echoing each behind a $ sign, and gives every commit the same fixed timestamp, which is why your commit IDs will equal the ones above.

gitsession.py: runs a session with real Git Python · gitsession.py
"""Plays a terminal session with real Git: each command is printed after "$ ", then everything it printed.

The session runs in a new, empty folder called learner/, which is also its home folder, so your own Git settings
change nothing, and GIT_CONFIG_NOSYSTEM=1 makes Git skip the computer's system-wide settings file too. Git never
looks for a repository above that folder, so a session cannot touch a repository you run it from. In the output,
"~" stands for that folder. Commit times are fixed, because a commit's ID covers the time it was made: with the
same files, message, name and time, every run makes the same commit IDs.

Each line of a script is one /bin/sh command (macOS and Linux), except "cd", which the session does itself. A line
that starts with "# " is printed as a comment. A command written as "! command" is meant to fail: its exit status
is printed and the session goes on. Any other command that fails stops the session with exit status 1.
"""

import os
import shutil
import subprocess
import sys
from pathlib import Path

HOME = Path("learner").resolve()
# One fixed moment in Git's own date format: seconds since 1970, then the time zone (India, UTC+05:30).
FIXED_TIME = "@1767240000 +0530"


def _environment():
    # Git's own folder comes first, so every command runs that Git directly (on macOS, /usr/bin/git is a small
    # launcher that looks Git up again on every call).
    git_folder = subprocess.run(["git", "--exec-path"], capture_output=True, text=True, check=True).stdout.strip()
    env = {
        "PATH": os.pathsep.join([git_folder, os.environ.get("PATH", "/usr/bin:/bin")]),
        "HOME": str(HOME),
        "XDG_CONFIG_HOME": str(HOME / ".config"),
        "GIT_CONFIG_NOSYSTEM": "1",
        "GIT_CEILING_DIRECTORIES": str(HOME.parent),
        "GIT_AUTHOR_DATE": FIXED_TIME,
        "GIT_COMMITTER_DATE": FIXED_TIME,
        "LANG": "C",
        "LC_ALL": "C",
        "TERM": "dumb",
    }
    if "TMPDIR" in os.environ:
        env["TMPDIR"] = os.environ["TMPDIR"]
    return env


def _with_tilde(text):
    """Writes the home folder as "~", however the operating system spells its path."""
    for spelling in sorted({str(HOME), os.path.realpath(HOME)}, key=len, reverse=True):
        text = text.replace(spelling, "~")
    return text


def session(script):
    """Runs the script's commands one by one, starting in an empty home folder."""
    shutil.rmtree(HOME, ignore_errors=True)
    HOME.mkdir()
    env = _environment()
    folder = HOME
    for line in script.strip().splitlines():
        command = line.strip()
        if not command:
            continue
        if command.startswith("# "):
            print(command)
            continue
        meant_to_fail = command.startswith("! ")
        if meant_to_fail:
            command = command[2:]
        print("$", command)
        if command.startswith("cd "):
            folder = (folder / command[3:].strip()).resolve()
            continue
        done = subprocess.run(command, shell=True, cwd=folder, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        print(_with_tilde(done.stdout), end="")
        if done.returncode != 0:
            print(f"[exit status {done.returncode}]")
            if not meant_to_fail:
                sys.exit(1)
        elif meant_to_fail:
            sys.exit(f"{command} was meant to fail, but it worked")
show_colors.py: names the colours of a coloured diff Python · show_colors.py
"""Reads git diff --color=always output and prints it as plain text, naming the colour of each changed line.

With --color-moved, Git gives lines that moved their own colours (bold magenta where they left, bold cyan where they
arrived), apart from the red and green of lines that really changed. Usage: git diff --color=always … | python3 show_colors.py
"""
import re
import sys

NAMES = {"1;35": "moved from: ", "1;36": "moved to:   ", "31": "deleted:    ", "32": "added:      "}
for line in sys.stdin.read().splitlines():
    match = re.match(r"\x1b\[([0-9;]*)m", line)
    plain = re.sub(r"\x1b\[[0-9;]*m", "", line)
    label = NAMES.get(match.group(1), "") if match and plain[:1] in "+-" else ""
    print(f"{label:<12}{plain}")

Interview questions

Fresher to mid-level · What is the difference between git merge -X ours and git merge -s ours?

-X ours is an option of the default ort strategy. Git merges as usual, and only where a region conflicts does it take our side instead of stopping; every non-conflicting change from the other branch is still merged. -s ours is a separate strategy that ignores the other branch’s content entirely: the merge commit’s tree is identical to our current tree, but the commit still has the other branch as a parent, so Git treats that branch as merged. Use -X ours to settle conflicts in your favour, and -s ours only to record that an old line of work is superseded. There is a -X theirs but no -s theirs.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    A reviewer says your refactor's diff is unreadable: a moved function appears as a long deletion and addition with your two real edits buried inside, and blame now points every moved line at you. What do you change on the command line, in configuration, and in how you split the commits?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    After a merge, a bug appears in code that neither branch changed on its own. How do you prove whether the merge commit itself introduced it, and how do you find the lines involved?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    Your code-review service must show, for 20,000 open pull requests, whether each still merges cleanly into main, and refresh that whenever main moves. Checking out each one is far too slow. How would you design the check?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A diff is computed from two snapshots: trees first, then rename pairing, then a line algorithm per file.
  • Myers and minimal minimise changed lines; patience and histogram anchor on unique or rare lines and often follow moved code better; ort merges with histogram.
  • Renames are inferred from similarity, 50 % by default, within diff.renameLimit; -C and --find-copies-harder add copies.
  • A three-way merge compares the base with both tips region by region: neighbouring changes conflict, and a change reverted on one side comes back.
  • With criss-cross histories there are several merge bases; ort merges them into a virtual base instead of picking one.
  • git merge-tree --write-tree merges without a working tree; trust its exit status and conflicted-file list.
  • --remerge-diff shows what a merge commit changed beyond the automatic merge, including dropped changes that git show hides.
  • -X ours settles conflicts in your favour; -s ours discards the other branch.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.