Two dots or three dots in git?

The answer might be zero dots

You rebase your commits and before you push you want to run a diff to make sure you didn’t mess up the rebase:

Terminal window
git diff origin/myBranch..myBranch

But the diff comes back with a bunch of code you haven’t seen before. A bunch of deleted files, migration you didn’t write, config file somebody else added this morning.

You didn’t mess up rhe rebase, 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 -> myBranch) 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 on myBranch. 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 the 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..myBranch
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.

But tax.ts shows as deleted because it was created in d0fe2fc, which does not exist in myBranch.

“Delete your teammate’s tax fix” is a completely honest answer to the question you asked — “how do I turn (the code at) main into myBranch”.

I often say that git rebase is a “smart cherry-pick”. Git figures out which commits need rebasing. It does that by calculating a merge base, which is the common ancestor of two branches.

Three dot diff is similar. git diff A...B doesn’t start at A, it starts at the merge base of A and B and diffs from there to B:

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

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

Terminal window
git diff main...myBranch
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.

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

As we’ve established, in git diff three dots means “start at the merge base.” Confusingly though, in git log, three dots means something completely different.

Three dot log shows the symmetric difference, which is every commit that’s on one branch or the other, but not both.

Terminal window
git log --oneline main..myBranch # the commits I added
Terminal window
de0e0d6 feat: add the export button
ef9cfee feat: add the export endpoint
Terminal window
git log --oneline main...myBranch # 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

Why would the developers of git have this concept of three dots completely change semantic meaning between two of the most common commands?

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 of a Github PR literally contains the 3-dot syntax?

https://github.com/git/git/compare/master...next

So if 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. Which diff is correct there?

Well assuming that you rebased because someone elses changes landed in main, neither one is going to give you an empty diff.

Terminal window
# won't help - shows files everyone else added
git diff origin/myBranch myBranch
# won't help - shows` additions made by others and yourself
git diff origin/myBranch...myBranch

So what is a developer to do? You essentially want to ensure that your three-dot diff is exactly the same before and after. So you could do something like this:

Terminal window
git branch myBranch-b4rebase # before you touch anything
git rebase main
# run both diffs and save the results to tmp files
git diff main...myBranch-b4rebase > /tmp/before.diff
git diff main...myBranch > /tmp/after.diff
# compare those diff outputs - you want this to be empty
diff /tmp/before.diff /tmp/after.diff

But that’s obviously super annoying. Fortunately Git ships a purpose-built command for this! Allow me to introduce range-diff:

Terminal window
# notice these ranges are identical to what we just used
git range-diff main...myBranch-b4rebase main...myBranch
# because the base `main` is the same in both ranges,
# this abbreviated syntax is equivalent:
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 between two ranges.

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 <range1> <range2>