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:
git diff main..myBranchAnd 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:
* 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 commitTwo 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?
git diff main..featurediff --git a/export.ts b/export.tsnew file mode 100644--- /dev/null+++ b/export.ts
diff --git a/tax.ts b/tax.tsdeleted file mode 100644--- a/tax.ts+++ /dev/nullexport.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.
Three dots compares against where you branched off
Section titled: Three dots compares against where you branched offgit 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:
git diff $(git merge-base A B) BWhich answers a more useful question: What did I do on this branch?
git diff main...featurediff --git a/export.ts b/export.tsnew file mode 100644--- /dev/null+++ b/export.tstax.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 → EThe trap: the dots mean something different in git log
Section titled: The trap: the dots mean something different in git logIn 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.
git log --oneline main..feature # the commits I addedde0e0d6 feat: add the export buttonef9cfee feat: add the export endpointgit log --oneline main...feature # every commit that isn't sharedd0fe2fc fix: correct the tax roundingde0e0d6 feat: add the export buttonef9cfee feat: add the export endpointSo put the two commands you likely want next to each other:
git log main..myBranch # the commits I addedgit diff main...myBranch # the changes those commits madeTurns 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-branchSoif you want to review your PR diff in your terminal, three dots is the command that matches what your reviewers will see:
git diff main...myBranchIn 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:
git diff origin/myBranch myBranchAfter 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:
git branch myBranch-b4rebase # before you touch anythinggit rebase main
git diff main...myBranch-b4rebase > /tmp/before.diffgit diff main...myBranch > /tmp/after.diffdiff /tmp/before.diff /tmp/after.diff # you want this emptyBut that’s super annoying. So thankfully Git also ships a purpose-built tool for this:
git range-diff main myBranch-b4rebase myBranchIt diffs the commits rather than the final trees, and prints a = next to every commit that came through untouched:
ef9cfee = 1: b554bfc feat: add the export endpointde0e0d6 = 2: 56c4686 feat: add the export buttonReach 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 |