Two dots or three dots in git?

The answer might be zero dots

You finish a feature, you want to look over everything you changed before you open the PR, so you run a diff:

Terminal window
git diff main..myBranch

And the diff comes back full of code you haven’t seen before, a lot of it being deleted. A migration you didn’t write. A config file your teammate added this morning.

You didn’t do that, but Git isn’t lying to you either. You just asked it the wrong question.

Every example in this post uses this repo:

Terminal window
* de0e0d6 (HEAD -> feature) feat: add the export button
* ef9cfee feat: add the export endpoint
| * d0fe2fc (main) fix: correct the tax rounding
|/
* 2c5c4e0 chore: bump deps
* 2c63f3c Initial commit

Two branches that split at chore: bump deps. You wrote the two feat: commits over on feature. Somebody else landed fix: correct the tax rounding on main after you branched off.

git diff A..B compares the two commits as snapshots. It answers exactly one question:

If I had the code at A, what would I need to change to end up with exactly the code at B?

Terminal window
git diff main..feature
diff --git a/export.ts b/export.ts
new file mode 100644
--- /dev/null
+++ b/export.ts
diff --git a/tax.ts b/tax.ts
deleted file mode 100644
--- a/tax.ts
+++ /dev/null

export.ts gets added, which you expected. And tax.ts gets deleted, which you did not.

That’s not a bug. You asked git how to turn main into feature, and feature does not contain tax.ts (you branched off before it existed). “Delete your teammate’s tax fix” is a completely honest answer to the question you asked. It just isn’t the question you meant to ask.

git diff A...B doesn’t start at A. It starts at the merge base (the last commit the two branches agreed on) and diffs from there to B. It is precisely this:

Terminal window
git diff $(git merge-base A B) B

Which answers a more useful question: What did I do on this branch?

Terminal window
git diff main...feature
diff --git a/export.ts b/export.ts
new file mode 100644
--- /dev/null
+++ b/export.ts

tax.ts is gone from the diff, because it was never yours to begin with. The three-dot diff doesn’t care what happened on main after you left.

Here’s the same idea on the graph. The only difference between the two commands is which commit sits on the left side of the comparison:

D---E ← feature
/
A---B---C ← main
↑ ↑
│ └─ git diff main..feature compares C → E
└───── git diff main...feature compares B → E

In git diff, three dots means “start at the merge base.” In git log, three dots means something else entirely: the symmetric difference, i.e. every commit that’s on one branch or the other, but not both.

Terminal window
git log --oneline main..feature # the commits I added
Terminal window
de0e0d6 feat: add the export button
ef9cfee feat: add the export endpoint
Terminal window
git log --oneline main...feature # every commit that isn't shared
Terminal window
d0fe2fc fix: correct the tax rounding
de0e0d6 feat: add the export button
ef9cfee feat: add the export endpoint

So put the two commands you likely want next to each other:

Terminal window
git log main..myBranch # the commits I added
git diff main...myBranch # the changes those commits made

Turns out, it was simply a design mistake:

Please unlearn dot-dot and three-dots when using “git diff”, which is not about ranges but about comparing two endpoints. If we were reinventing Git today from scratch, we would make “git diff A..B” an error. You can consider it a bug that the command accepts a range notation, but this will not change any time soon without a large fight to find and fix uses of the syntax in scripts by longtime Git users have written over the years.

Allowing dot-dot on the command line of “git diff”, instead of diagnosing them as errors and dying, was a stupid mistake we (well, mostly Linus, but I am willing to take the blame too) made due to laziness when we reused the machinery, which we invented to parse the command line of “log” family of commands to specify ranges, to parse the command line of “diff”, which accidentally ended up allowing the syntax for ranges where it shouldn’t be allowed.

And worse yet, since there was only dot-dot and three-dots came much later, “git diff A..B” ended up comparing the endpoints A and B, because there didn’t even A…B notation exist.

This is not limited to you but any user of modern Git is better off to pretend “git diff A..B” does not exist; please unlearn dot-dot and three-dots when using “git diff” and you’d be happier.

-Junio C Hamano, Git maintainer, Dec 2019

But what exactly is Junio suggesting to do instead? Well the two-dot variant git diff A..B is exactly equivalent to git diff A B, so that’s easy.

The three-dot syntax git diff A...B is equivalent to git diff --merge-base A B. That does read a lot nicer, but it’s also more to type.

Have you ever noticed that the url on the “Files changed” tab on a PR shows the 3-dot syntax?

https://github.com/owner/repo/compare/main...my-branch

Soif you want to review your PR diff in your terminal, three dots is the command that matches what your reviewers will see:

Terminal window
git diff main...myBranch

In part 2 of the git series I said that the most important step after a rebase is running a diff, and that you want it to come back empty. Zero/two dots is correct there:

Terminal window
git diff origin/myBranch myBranch

After an interactive rebase to squash or reorder your own commits, the base didn’t move. Every commit hash changed, but the final tree should be byte for byte identical to what you started with. Two-dots compares those trees directly, and empty output proves you didn’t drop anything.

The moment you rebase onto a main that has moved, though, that check falls apart. The diff fills up with your teammates’ commits and you’re left squinting at it trying to decide whether your own work survived.

Two-dots doesn’t work there because the rebase will have incorporated others changes that would pollute the 0/2 dot syntax. You essentially want to ensure that your three-dot diff is exactly the same before and after:

Terminal window
git branch myBranch-b4rebase # before you touch anything
git rebase main
git diff main...myBranch-b4rebase > /tmp/before.diff
git diff main...myBranch > /tmp/after.diff
diff /tmp/before.diff /tmp/after.diff # you want this empty

But that’s super annoying. So thankfully Git also ships a purpose-built tool for this:

Terminal window
git range-diff main myBranch-b4rebase myBranch

It diffs the commits rather than the final trees, and prints a = next to every commit that came through untouched:

Terminal window
ef9cfee = 1: b554bfc feat: add the export endpoint
de0e0d6 = 2: 56c4686 feat: add the export button

Reach for range-diff when you need to know which commit changed.

I want to know…Command
What does my branch change? (my PR diff)git diff main...myBranch
How exactly do these two commits differ?git diff A B
Did my local branch drift from remote?git diff origin/myBranch myBranch
What commits are on my branch?git log main..myBranch
Did my commits change after a rebase?git range-diff <base> <rev1> <rev2>
git range-diff main myBranch-b4rebase myBranch