Comparing Changes with Git diff
We see with real output which comparison git diff makes between the working tree, the index and HEAD, how to read diff output, summary views like --stat, --name-only and --word-diff, diffs between commits, and the difference between two dots and three dots (--merge-base).
Version note: The commands in this post were tested with Git 2.54.
Guess First
We are in the bookstore repo we have been growing over the previous posts. In src/books.js you raise the price of Dune from 12 to 14 and move the file into the staging area with git add. Without committing, you then change the price of Neuromancer in the same file from 9 to 11. The short status looks like this:
$ git status -s
MM src/books.js
Two M's side by side: the left one describes the change in the index, the right one the change in the working tree. Here is the question: if you type a plain git diff now, which price change shows up on the screen? Dune, Neuromancer, or both? The answer is in the next section, but write down your own guess first.
Three Areas, Three Comparisons
In Git a file can exist in three states at the same time: as it is in the last commit, as it is in the index and as it is on disk. git diff puts two of these three side by side. The option you pass decides which two.
$ git diff
diff --git a/src/books.js b/src/books.js
index e7fa590..4e89fb6 100644
--- a/src/books.js
+++ b/src/books.js
@@ -1,6 +1,6 @@
const books = [
{ id: 1, title: "Dune", price: 14 },
- { id: 2, title: "Neuromancer", price: 9 },
+ { id: 2, title: "Neuromancer", price: 11 },
];
function findBook(id) {
If you guessed Neuromancer, you were right. A plain git diff compares the working tree with the index, so it shows "the changes you have not staged yet". Dune is already in the index, so it does not appear as a difference here; its line sits there as an unchanged context line.
To see what you have staged, use --staged. This time the index is compared with the last commit, that is, with HEAD:
$ git diff --staged
diff --git a/src/books.js b/src/books.js
index 300383a..e7fa590 100644
--- a/src/books.js
+++ b/src/books.js
@@ -1,5 +1,5 @@
const books = [
- { id: 1, title: "Dune", price: 12 },
+ { id: 1, title: "Dune", price: 14 },
{ id: 2, title: "Neuromancer", price: 9 },
];
--cached is the older name for the same thing; the two behave identically. Make git diff --staged a habit right before every commit. That is exactly what will go into the commit, nothing more.
The third comparison is the sum of the other two: HEAD against the working tree. It skips the index and shows in one go everything that changed on disk since the last commit:
$ git diff HEAD
diff --git a/src/books.js b/src/books.js
index 300383a..4e89fb6 100644
--- a/src/books.js
+++ b/src/books.js
@@ -1,6 +1,6 @@
const books = [
- { id: 1, title: "Dune", price: 12 },
- { id: 2, title: "Neuromancer", price: 9 },
+ { id: 1, title: "Dune", price: 14 },
+ { id: 2, title: "Neuromancer", price: 11 },
];
function findBook(id) {
A shipping analogy helps. The things on your desk are the working tree, the things you have put in the box are the index, and the box that has already left the door is the commit. git diff compares the desk with the box, git diff --staged compares the box with the last box that left, and git diff HEAD compares the desk with the last box that left.
Reading Diff Output
The output looks crowded at first, but every line has one job. The diff --git a/src/books.js b/src/books.js line tells you which file is being compared; a/ stands for the old side and b/ for the new side. The two short hashes in index e7fa590..4e89fb6 100644 are the blob objects for the old and new contents of the file. You can look inside them with git cat-file -p, which we used in the post about the inside of a commit. 100644 is the file mode: an ordinary, non-executable file.
The --- and +++ lines name the old and new sides once more. The real information is in the hunk header that starts with @@ -1,6 +1,6 @@. Read it as "6 lines starting at line 1 of the old file became 6 lines starting at line 1 of the new file". The lines below it come in three kinds: lines starting with a space are unchanged context, lines starting with - were removed and lines starting with + were added. By default Git shows three lines of context so you can find your way around the change; -U1 or -U0 narrows that down.
Git has no notion of "a line changed". A changed line is shown as the old line being removed and a new one being added. That is why you see one - and one + line even when you change a single number. The general name for this output is a patch, and this format is the common language of everyone who mails patches around or reviews changes in a pull request.
Summary Views
Let's commit the price changes and move on. This time you add a discount parameter to the cartTotal function in src/cart.js and reword the sentence in the README a little. With two changed files, the line-by-line diff gets long. If you want to see the big picture first:
$ git diff --stat
README.md | 2 +-
src/cart.js | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
$ git diff --name-only
README.md
src/cart.js
$ git diff --name-status
M README.md
M src/cart.js
--stat shows how many lines were added and removed in each file, with a small bar; it is ideal for choosing where to start when you review a large change. --name-only gives just the file names and is very handy in scripts: "which files changed in this commit, run the linter only on those". --name-status adds a letter in front: M modified, A added, D deleted, R renamed. In CI/CD pipelines, decisions like "skip the tests if only docs/ changed" are usually made by looking at one of these outputs.
Word by Word: --word-diff
In plain-text files like the README, a line-based diff is tiring. When a single word changes in a long paragraph, the whole line looks as if it was deleted and rewritten:
$ git diff README.md
diff --git a/README.md b/README.md
index eab8800..027a113 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
# Bookstore
-A tiny bookstore app used in the Git & GitHub series.
+A small bookstore app used in the Git and GitHub series.
To see the same change at word level:
$ git diff --word-diff README.md
diff --git a/README.md b/README.md
index eab8800..027a113 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
# Bookstore
A [-tiny-]{+small+} bookstore app used in the Git [-&-]{+and+} GitHub series.
[-...-] marks what was removed and {+...+} what was added. In a colour terminal, --word-diff=color is even easier to read; the brackets disappear, removed text shows in red and added text in green. For documentation, Markdown or translation files this option is much kinder to your eyes.
Diffing Between Commits
When you give git diff two commits, the working tree and the index drop out; Git compares only those two snapshots. The form you will use most is seeing what the last commit brought in:
$ git diff HEAD~1 HEAD
diff --git a/src/books.js b/src/books.js
index 4e89fb6..a7699ec 100644
--- a/src/books.js
+++ b/src/books.js
@@ -1,6 +1,7 @@
const books = [
{ id: 1, title: "Dune", price: 14 },
{ id: 2, title: "Neuromancer", price: 11 },
+ { id: 3, title: "Snow Crash", price: 10 },
];
function findBook(id) {
HEAD~1 means the commit before HEAD. You can also use the short hashes you see in git log --oneline. To narrow the comparison down to a single file, give the path after --:
$ git diff 7ff0565 HEAD -- README.md
diff --git a/README.md b/README.md
index eab8800..027a113 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
# Bookstore
-A tiny bookstore app used in the Git & GitHub series.
+A small bookstore app used in the Git and GitHub series.
It does not matter how many commits are in between. Git does not look at the intermediate steps; it compares the contents at the two ends. If the file had changed five times since the first commit and ended up exactly where it started, this command would show no difference at all.
Two Dots or Three?
This section needs a branch. We will cover branches in detail in the next post; for now it is enough to know that a branch is a separate line of commits. After committing the README, we created a branch called discount and committed the discount change there. Then we went back to main and added Snow Crash to the book list. The picture looks like this:
$ git log --oneline --graph --all
* 5bcf046 Add Snow Crash
| * 86303f4 Add discount to cart total
|/
* 6651492 Reword README
* a5a7a57 Update book prices
* 6dde20b Add cart total
* f9b9660 Add .gitignore
* 02ea975 Add book list
* 7ff0565 Add README
Now let's ask "what does the discount branch change?" in two different ways:
$ git diff main..discount --stat
src/books.js | 1 -
src/cart.js | 4 ++--
2 files changed, 2 insertions(+), 3 deletions(-)
$ git diff main...discount --stat
src/cart.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
The two-dot form is the same as git diff main discount: it compares the current state of the two ends. Since Snow Crash does not exist on the discount branch, the output shows a - line as if this branch deleted the book. But discount did not delete anyone's book; it simply has not seen the new commit on main yet.
The three-dot form first finds the common ancestor of the two branches and shows the change from that point to discount. That is the right answer to "what has this branch done since it split off?". The diff you see on a pull request page works the same way. To see the common ancestor yourself:
$ git merge-base main discount
66514929b4fe53a14d1a73a9334836979ca13ccf
$ git diff --merge-base main discount --stat
src/cart.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
--merge-base is the long and more readable spelling of the three dots, available since Git 2.30. In scripts you may prefer it because it states your intent clearly. We will come back to the common ancestor in the merge post, because a three-way merge is built exactly on this commit.
Common Pitfalls
The first pitfall is a new file not showing up in the diff. A file Git does not track yet, an untracked file, never appears in git diff output:
$ git status -s
?? src/orders.js
$ git diff --stat
$ git add -N src/orders.js
$ git diff
diff --git a/src/orders.js b/src/orders.js
new file mode 100644
index 0000000..1daf29c
--- /dev/null
+++ b/src/orders.js
@@ -0,0 +1,3 @@
+const orders = [];
+
+module.exports = { orders };
git add -N (intent-to-add) records only "I am going to track this file" without staging its contents. That way the new file appears in the diff too.
The second is reading an empty diff after git add as "my changes are gone". They are not gone, they moved into the index; git diff --staged shows them.
The third is that two dots and three dots mean different things in git log and git diff. git log main..discount means "commits that are on discount but not on main", and in this post's example it lists only 86303f4. git diff main..discount does not care about the list of commits; it compares the contents of the two ends. If you want to know what a pull request brings in, use three dots with diff.
The fourth is trailing whitespace. Stray spaces your editor does not show pollute the diff. Imagine you left two spaces at the end of the first line of src/orders.js. git diff --check catches them with line numbers and exits with a non-zero status when it finds a problem, which makes it usable in a pre-commit hook or a CI step:
$ git diff --check
src/orders.js:1: trailing whitespace.
+const orders = [];
A Small Experiment
★☆☆ Change one line in src/cart.js, stage it, then change another line. Before running them, predict the number of changed lines in the output of git diff, git diff --staged and git diff HEAD, then check.
★★☆ Compare the output of git diff HEAD~3 HEAD --stat with git log --stat -3. Why might the line totals not match? Hint: one shows the two ends, the other shows each commit separately.
★★★ On main, commit a small change to src/cart.js, the file the discount branch also changed. Then compare the output of git diff main..discount and git diff main...discount and explain which branch each line comes from.
We used the discount branch briefly in this post but did not talk about what is underneath. In the next post we will see that a branch is really a movable pointer to a commit, what HEAD is for, how to move between branches with git switch and how to get out of a detached HEAD.