Skip to main content
RunBook Academy

Git, CI/CD & GitOpsVII · Repository InspectionInspection

git diff three ways — working tree, index, and HEAD

Intermediate⏱ ~21 mingit

What you'll learn

  • Distinguish the three default diffs: working tree vs index, index vs HEAD, and working tree vs HEAD
  • Use --cached to compute the diff between the index and HEAD
  • Use --stat to summarise a diff and --word-diff for inline highlights
  • Use the <a>..<b> syntax to compute the diff between any two commits
  • Recognise the right diff for each production case: pre-commit, staged preview, deployed state

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

git status answers the question “are there changes?”. git log answers the question “what was committed?”. git diff answers the question “what changed, byte by byte?” — but the answer depends on which two trees are being compared. The default git diff compares the working tree to the index, which is rarely the question an infrastructure engineer is asking. The three possible diffs in a Git repository are:

  • Working tree vs index — the default git diff. The diff of unstaged changes. The bytes that will be discarded by git restore <path>.
  • Index vs HEADgit diff --cached (or --staged). The diff of staged changes. The bytes that will be recorded in the next commit.
  • Working tree vs HEADgit diff HEAD. The full diff of every change on top of the last commit, staged or not. The bytes that the next commit will not record unless the unstaged changes are first staged.

The three diffs answer three different production questions. The wrong choice is the difference between “I see what I am about to commit” and “I see what I would have committed if I had remembered to stage everything”.

The three diffs in detail

The default git diff (no arguments) compares the working tree to the index. Every line that differs between the two is printed. The diff is the unstaged change: the bytes that have been edited in the working tree but not yet promoted to the index.

git diff
# diff --git a/terraform/main.tf b/terraform/main.tf
# index 1b2c3d4..5e6f7a8 100644
# --- a/terraform/main.tf
# +++ b/terraform/main.tf
# @@ -12,7 +12,7 @@ module "router" {
#   source  = "git::ssh://git@github.com/example/router.git"
# -  version = "3.2.6"
# +  version = "3.2.7"
# }

git diff --cached (synonym: --staged) compares the index to HEAD. The diff is the staged change: the bytes that will be recorded in the next commit. This is the diff a CI pipeline should gate on, because it is the diff that the commit will produce.

git diff --cached
# (same output as the previous example, because the change was
# already staged)

git diff HEAD compares the working tree to HEAD. The diff is the combined change: every byte that differs between HEAD and the working tree, regardless of whether the bytes are staged. This is the diff a code-review tool typically shows, because it shows everything the next commit could produce, including unstaged edits.

flowchart LR
    WT["Working tree"] -->|"git diff (default)"| IDX["Index"]
    IDX -->|"git diff --cached"| HD["HEAD"]
    WT -->|"git diff HEAD"| HD
    HD -->|"git diff <a>..<b>"| ANY["Any commit"]
    WT -->|"git diff <a>..<b>"| ANY

The three diffs are the three views of “what changed?”. The correct view depends on the question:

  • Pre-commit, no staging yet: git diff shows the unstaged edits. The right command for “did my editor save the file correctly?”.
  • Post-staging, pre-commit: git diff --cached shows the candidate commit. The right command for “what will the next commit record?”.
  • Pre-commit, mixed staging: git diff HEAD shows everything on top of HEAD, regardless of staging. The right command for “what is the complete set of changes I have made?”.

Diffing any two commits

The <a>..<b> syntax compares two arbitrary revisions. The diff is the change in the tree at <b> relative to the tree at <a>. The order matters: <a>..<b> is the diff from <a> to <b>, which is the changes that were applied by commits between <a> and <b>.

# Diff between two tags
git diff v3.2.6..v3.2.7 -- terraform/
# diff --git a/terraform/main.tf b/terraform/main.tf
# ...

# Diff between main and a feature branch
git diff main..feature/edge-mtu -- terraform/

# Diff between a commit and its parent (same as git show <rev>)
git diff 8a3f9d2~1..8a3f9d2 -- terraform/main.tf

The &lt;a&gt;..&lt;b&gt; syntax is the right primitive for “what changed between two releases?” or “what is on this branch that is not on main?”. The &lt;a&gt;...&lt;b&gt; (three-dot) syntax is different: it shows the diff between the merge base of &lt;a&gt; and &lt;b&gt; and &lt;b&gt;, which is the diff of the changes made on the branch since it diverged from &lt;a&gt;.

# Three-dot: what did the feature branch change since diverging from main?
git diff main...feature/edge-mtu -- terraform/

The two-dot and three-dot forms are the same when the second argument is a direct ancestor of the first. They differ when the two arguments have diverged: the two-dot shows the diff between the two tips, the three-dot shows the diff of the branch’s contribution since the merge base.

Summary and word-level diffs

Two flags change the rendering of a diff:

  • --stat — append a summary table to the diff. Each changed file is listed with the number of additions and deletions, and a histogram bar. The full diff is still printed; the --stat is an additional section at the end.
    git diff --stat HEAD~3..HEAD
    # terraform/main.tf      |  2 +-
    # modules/router/versions.tf |  2 +-
    # deploy/prod/main.tf    | 12 ++++++------
    # 3 files changed, 8 insertions(+), 8 deletions(-)
  • --word-diff — change the inline renderer from line-oriented (- and + lines) to word-oriented ([-old-]{+new+} inline markers). The right form for prose files (Markdown, YAML, documentation) where a single line is not a unit of change. Add --word-diff=color for terminal-friendly highlighting.
    git diff --word-diff HEAD~3..HEAD -- README.md

The --stat flag is the right summary for a CI log and a code review. The --word-diff flag is the right form for inspecting documentation and configuration changes where one line is not meaningful.

flowchart LR
    D["git diff"] --> S["--stat"]
    D --> W["--word-diff"]
    D --> N["--name-only"]
    D --> C["--cached"]
    D --> H["-- HEAD"]
    S --> Use1["CI summary, PR review"]
    W --> Use2["docs, YAML, prose"]
    N --> Use3["scripts that need file list"]
    C --> Use4["candidate commit"]
    H --> Use5["combined view"]

The production diff decision tree

For every “what changed?” question, the answer is one of:

QuestionCommand
What did I just edit?git diff
What will the next commit record?git diff --cached
What is the full set of my pending changes?git diff HEAD
What changed between two releases?git diff &lt;tag1&gt;..&lt;tag2&gt;
What did this branch change since diverging?git diff &lt;base&gt;...&lt;branch&gt;
What changed in this one commit?git show &lt;rev&gt;
What is the diff of one path at one commit?git show &lt;rev&gt; -- &lt;path&gt;

The first three are the everyday forms. The fourth and fifth are the release and branch forms. The last two are the single-commit forms covered in the previous lesson.

Production discipline

  1. Decide which diff you want before the command. “What changed?” is ambiguous; the three diffs answer three different questions. The production rule is to name the question out loud: “what will the commit record?” → --cached.
  2. Gate CI on --cached, not on the working tree. The CI pipeline lints the bytes that will be recorded, not the bytes that the engineer happens to have on disk.
  3. Use two-dot for release notes, three-dot for branch diffs. The two-dot answers “what changed between X and Y?”; the three-dot answers “what did this branch contribute?”.
  4. Use --stat for CI logs and summaries. The histogram and the file list are the right shape for a CI summary; the full diff is for the inspector.
  5. Use --word-diff for prose and YAML. Line-oriented diffs are not meaningful for documentation and configuration files where a single line is not a unit of change.

Cross-course references

  • Linux for Production Sysadmins - Part IX (Filesystem) distinguishes the same three views of “what is on disk?”: working tree, index, and committed state.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) uses git diff --cached as the canonical pre-commit lint target for Ansible playbooks.
  • Terraform for Production Sysadmins - Part IX (State) uses git diff main..HEAD -- terraform/ as the canonical “what is in this plan?” query before terraform apply.

Quiz

Knowledge check · 4 questions

  1. Q1. A CI pipeline lints the bytes that will be recorded in the next commit. Which diff command is the correct lint target?

  2. Q2. The two-dot syntax `git diff &lt;a&gt;..&lt;b&gt;` and the three-dot syntax `git diff &lt;a&gt;...&lt;b&gt;` produce the same output whenever the two revisions have diverged (neither is an ancestor of the other).

  3. Q3. Name the three trees Git uses to compute a diff, and give the command for each of the three diffs.

  4. Q4. Diagnose why a CI pipeline silently approved a Terraform change that the engineer did not see, and recommend the right diff command for the pipeline.

    An engineer modifies `terraform/main.tf` to bump a module version. The edit is visible in their editor but not staged. The CI pipeline runs `git diff` (the default, working tree vs index) and sees the change. The pipeline approves the change. The engineer then runs `git commit` without `--all`, staging only some files. The committed change does not include the module version bump — only the unstaged view saw it. The CI pipeline's approval was actually for the unstaged bytes, which were discarded by the commit.

Passing score: 75%. Answers are checked in this browser.