Git diff: check what changed before you commit it
The commit is ready, the message is written, and the only thing left is to look at what is about to go in. Then git diff prints nothing, or prints four thousand lines, and the review step quietly turns into a guess. Both outcomes come from the same cause: git diff is not one comparison. It is a family of comparisons between three different places, and printing nothing usually means the question asked was not the question intended.
Three places, and which pair each form compares
Git holds the same file in three states at once. The working tree is what is on disk. The index, also called the staging area, is what git add has recorded. HEAD is the last commit. Every form of the command picks two of those three and reports the difference.
| Command | Compares | Answers |
|---|---|---|
git diff |
working tree against index | what is still not staged |
git diff --staged |
index against HEAD | what the next commit will contain |
git diff HEAD |
working tree against HEAD | everything changed since the last commit |
git diff <commit> |
working tree against that commit | drift from a branch tip or tag |
git diff <a> <b> |
two commits | what separates two points |
The form that matters immediately before a commit is git diff --staged. The documentation lists --cached as its synonym, and the two are interchangeable. This is the form most often missed, and it explains the empty output: after git add -A, nothing is left between the working tree and the index, so the bare git diff has nothing to say. The change has not vanished. It moved one place to the right.
There is a further wrinkle in git diff --staged with no commit named. It defaults to HEAD, and on an unborn branch where HEAD does not yet exist it shows all staged changes instead of failing.
Two dot notations behave differently from what the shape suggests. git diff A..B is the same as git diff A B, a comparison of two endpoints. git diff A...B with three dots compares the merge base of A and B against B, which is what a reviewer usually wants when asking what a branch added. The manual is explicit that neither notation means a range in the sense git log uses, because diff compares endpoints rather than walking history.
Reading a hunk header without guessing
Diff output has a fixed shape, and knowing the shape removes most of the friction.
The header names the two sides as a/path and b/path, where a is the before side and b the after side. When a file is added, the before side is /dev/null. When it is deleted, the after side is. Mode changes and renames appear as extra header lines above the content.
Then come hunks, each starting with a line of the form @@ -12,7 +12,9 @@. The first pair is the start line and line count on the before side, the second pair the same on the after side. Reading the example: seven lines starting at line 12 became nine lines starting at line 12. The text after the second @@ is a guess at the enclosing function or section, useful for orientation and not part of the data.
Inside a hunk, a leading space is context, a minus is removed, a plus is added. Three lines of context appear on each side of a change by default, and -U<n> or --unified=<n> changes that count. Setting it to zero produces the tightest possible patch and makes the result much harder to read; raising it to ten is often the faster move when a change needs to be understood rather than applied.
One detail catches people out. A modified line appears as a removal followed by an addition, because the diff is line based. There is no concept of an edited line. --word-diff re-renders the hunk with changes marked inside the line, which turns a wall of paired minus and plus lines into something readable when a paragraph of prose or a long argument list was touched.
Shrinking the diff until review is possible
A four thousand line diff is not reviewable, so the useful skill is narrowing it before reading.
Start with shape rather than content. --stat prints a file list with a bar graph of added and removed lines. --numstat prints the same counts in plain columns for machines. --shortstat prints only the final total. --compact-summary adds file creation, deletion and mode changes to the stat output, which is the fastest way to notice that something became executable or that a symlink appeared. When only the list of touched paths is needed, --name-only gives it, and --name-status adds a letter per file for added, modified, deleted or renamed.
Then narrow by path. Everything after a double hyphen is a pathspec, so git diff --staged -- src/ restricts the comparison to one directory. Pathspec magic inverts it: an exclude pattern drops the noise that is technically part of the change but carries no decision, such as a lock file or generated output.
Then narrow by kind of change. Reformatting is the usual reason a diff is unreadable. -b ignores changes in the amount of whitespace, -w ignores whitespace entirely, and --ignore-blank-lines drops hunks whose lines are all blank. -I<regex> drops hunks whose every line matches a pattern, which handles a regenerated timestamp header. These options are for reading, not for committing: what gets committed is still the full change.
Rename detection is on by another flag. -M, also spelled --find-renames, treats a delete and add pair as a rename when enough of the file is unchanged, with a default similarity threshold of 50 percent. -M100% limits detection to exact renames. Without this, moving a file reads as several hundred deleted lines and several hundred added ones.
Finally, the algorithm itself can be changed. --diff-algorithm accepts myers, which is the current default, plus minimal, patience and histogram. On a file where a block was moved, patience and histogram frequently produce a diff that matches what a person would have drawn by hand.
Comparing things Git is not tracking
Two forms step outside the repository, and both are underused.
git diff --no-index <path> <path> compares two files or directories on disk with no repository involved. It is a colorized, word aware replacement for the system diff command, and it works anywhere. The option can be omitted when the command runs outside a working tree, or inside one where at least one path points outside the tree. This form implies --exit-code, which matters when it is used in a script.
git diff <blob> <blob> compares two blob objects directly, which is how a single file gets compared between two commits without producing the rest of the diff.
For a graphical side by side view, git difftool accepts the same arguments and hands each pair to a configured external tool. It is worth setting up once, because a three way merge conflict is genuinely hard to read as unified text and easy to read in two columns. For file trees on a Mac, a file manager that opens a terminal in the folder already selected removes the step of typing the path twice.
Using the diff as a gate rather than a glance
Reading a diff is a human step that gets skipped under time pressure. Two options make part of it automatic.
--check warns when a change introduces whitespace errors or leaves a conflict marker behind, and exits non-zero if it finds any. The default definition of a whitespace error is trailing whitespace, including a line made only of whitespace, and a space immediately followed by a tab inside the leading indent. core.whitespace adjusts that definition. The conflict marker case is the one that pays for itself: a stray marker left after a resolution is invisible in a large diff and obvious to this check.
--exit-code makes the command behave like the system diff, returning 1 when differences exist and 0 when they do not. --quiet implies it and suppresses output, which is the right form for a script that only needs the answer. Note that --check and --exit-code are documented as incompatible, so a pre-commit hook needs to run them as separate steps.
--ws-error-highlight controls where whitespace errors are highlighted. Without it, and without diff.wsErrorHighlight set, only errors on new lines are marked. Setting it to all marks old and context lines too, which is how a whitespace change that came in from someone else becomes visible.
Set the defaults once so every diff is readable
Most of the options above are worth turning on permanently rather than remembering under pressure, and they live in the same config that every repository on the machine reads.
diff.algorithm holds the default algorithm. Setting it to histogram costs nothing noticeable on a normal repository and produces smaller, better aligned hunks on files where blocks were moved. When the default is needed back for one command, --diff-algorithm=default restores it explicitly.
diff.colorMoved is the one that changes the most. Without it, a block that was cut from one place and pasted into another shows as a large deletion and a large addition, and reading both to confirm they match is exactly the kind of check that gets skipped. With it set, moved blocks are painted in their own colors. The mode defaults to no when the option is absent and to zebra when given with no mode, and blocks mode detects moved runs of at least 20 alphanumeric characters. A related setting, diff.colorMovedWS, controls how much whitespace difference still counts as a move, which matters when the paste landed at a different indent level.
diff.wsErrorHighlight decides where whitespace errors are marked. The default marks only new lines, so setting it to all is how a whitespace problem inherited from elsewhere becomes visible instead of staying invisible in context lines.
Two more are worth knowing without making them defaults. -W, spelled --function-context, expands each hunk to the whole enclosing function, which is the right amount of context for judging a one line change. --inter-hunk-context=<n> fuses hunks that sit close together, so a change scattered across a few adjacent lines reads as one edit rather than four.
Where the review actually breaks down
The commands above are not the hard part. The hard part is that reviewing a change requires three things at once: the list of touched files, the content of the hunks, and the file on disk in its surrounding folder. In a normal setup those live in three separate windows, and each switch costs the place in the diff that was just being read.
That cost is why --stat is worth making a habit. It answers the first question in one screen, so the second question can be asked about one path instead of forty. It is also why the closing gate matters more than the reading: a check that runs every time beats a review that happens when there is time for it.
For people whose work is mostly files rather than mostly code, the same problem appears one level up. A folder window, a terminal in that folder, and a model that can be asked about what changed are three separate places to stand. Combining them is what a file manager with a built in terminal is for, and the practical difference shows up in how many times a path has to be retyped. The pricing and FAQ pages cover what that kind of tool costs and where it stops.
What to change first
Add one alias for git diff --staged --stat and run it as the last step before every commit, because it answers what is in this commit in one screen. Then put git diff --check into a pre-commit hook so the whitespace and conflict marker case stops depending on attention. If the folder, the terminal and the diff are currently three windows, Atriens is one way to put them in one.
Frequently asked questions
Why does git diff show nothing when files were clearly changed?
The changes are staged. A bare git diff compares the working tree against the index, and git add moved the change into the index, so nothing is left between them. Run git diff --staged to see it, or git diff HEAD to see staged and unstaged changes together.
What is the difference between git diff --cached and git diff --staged?
Nothing. The documentation lists --staged as a synonym of --cached, and both compare the index against the named commit, defaulting to HEAD. --staged reads more clearly in a script that someone else will maintain.
How can a diff be read when the whole file was reindented?
Add -w to ignore whitespace entirely, or -b to ignore only changes in the amount of whitespace. Both affect the display, not what gets committed. If a block was also moved, --diff-algorithm=histogram often produces a much smaller diff than the default.
How can two files outside a repository be compared with git diff?
Use git diff --no-index <path1> <path2>. It works with no repository present and gives colorized, word aware output. The option can be dropped when the command runs outside a working tree. Note that this form implies --exit-code, so it returns 1 whenever the files differ.
Does git diff show new files that were never added?
No. An untracked file is not in the index or in HEAD, so no comparison includes it. git status lists it, and git add -N <path> records it as an intent to add, after which its content appears in the diff.