In the previous lesson we learned to decide precisely what goes into each commit. But there is an earlier question that git status does not answer: what exactly have I changed?

git status tells you which files have changed and which area they are in. It does not tell you which lines. That difference is enormous: committing without having read your own changes is the commonest way of getting a debugging console.log, a test password, a commented-out block of code or a change you thought you had undone into the history.

git diff is the tool that answers the question. In this lesson we will look at its three fundamental forms — and why they produce different results depending on which areas they compare — learn to read the unified diff format line by line (a format that shows up in Git, in code reviews, in patches and across half of computing), and go through the options that make a difficult output readable.

By the end you should have picked up one habit: git diff --staged right before every git commit.

Contents

  1. The three forms of git diff and what each one compares
  2. git diff: working tree against the staging area
  3. git diff --staged: staging area against the last commit
  4. git diff HEAD: everything that has changed
  5. Reading the unified diff format step by step
  6. Comparing specific commits
  7. Limiting the comparison to files or paths
  8. Options that make the output readable
  9. git difftool: comparing with a visual tool
  10. The habit of reviewing before committing

  1. The three forms of git diff and what each one compares

The whole apparent mystery of git diff dissolves with one idea: it always compares two of the three areas, and the form you use decides which two.

graph LR
    WT["WORKING<br/>TREE"]
    IDX["STAGING<br/>AREA"]
    REPO["LAST COMMIT<br/>(HEAD)"]

    WT ---|"git diff"| IDX
    IDX ---|"git diff --staged"| REPO
    WT -.-|"git diff HEAD"| REPO

As a table:

Command Compares Answers the question
git diff Working tree ↔ Staging area What have I changed and not yet staged?
git diff --staged Staging area ↔ HEAD What is going into the next commit, exactly?
git diff --cached Identical to --staged (An older synonym, still working)
git diff HEAD Working tree ↔ HEAD What has changed in total since the last commit?
git diff <sha1> <sha2> Two commits What changed between these two versions?

Two consequences worth taking on board from the start:

  • A bare git diff does not show staged changes. If you stage everything with git add -A and then run git diff, the output will be empty. It is not that you have changed nothing: it is that there is no difference between your disk and the staging area. This is bewilderment number one with git diff.

  • git diff HEAD is the sum of the other two. Staged or not, if it differs from the last commit, it shows up.

  • None of the three shows untracked files. A new file you have never added has no "previous version" to compare against, so git diff ignores it completely. That is what git status is for.

  1. git diff: working tree against the staging area

Back to Ana's repository. She has just tweaked styles.css and has staged nothing yet:

cd ~/projects/task-manager
git status -s
 M styles.css
git diff
diff --git a/styles.css b/styles.css
index 2c9d4e6..8f1a3b7 100644
--- a/styles.css
+++ b/styles.css
@@ -12,4 +12,5 @@ body {
 #list li {
   padding: 0.5rem 0;
-  border-bottom: 1px solid #ddd;
+  border-bottom: 1px solid #e5e7eb;
+  cursor: pointer;
 }

Three seconds and she knows what she did: she softened the border colour and added the pointer cursor. No surprises, no debugging leftovers.

Now she stages the change and repeats:

git add styles.css
git diff
(empty output)

Precisely: the disk and the staging area agree, so there is nothing to show. To see that change she has to ask for the other comparison.

  1. git diff --staged: staging area against the last commit

This is the most important form of all, because it shows literally what you are about to commit:

git diff --staged
diff --git a/styles.css b/styles.css
index 2c9d4e6..8f1a3b7 100644
--- a/styles.css
+++ b/styles.css
@@ -12,4 +12,5 @@ body {
 #list li {
   padding: 0.5rem 0;
-  border-bottom: 1px solid #ddd;
+  border-bottom: 1px solid #e5e7eb;
+  cursor: pointer;
 }

--cached is an exact and older synonym. --staged was added in Git 1.6 because it read more clearly; use whichever you prefer, though --staged is the plainer of the two.

The MM case seen through diff

This is where git diff proves its worth. Ana carries on working and adds one more line to styles.css after staging:

#list li {
  padding: 0.5rem 0;
  border-bottom: 1px solid #e5e7eb;
  cursor: pointer;
  transition: opacity 0.2s;
}
git status -s
# → MM styles.css

Now the three forms give three different answers:

git diff                # what is NOT going in
@@ -14,3 +14,4 @@
   border-bottom: 1px solid #e5e7eb;
   cursor: pointer;
+  transition: opacity 0.2s;
 }
git diff --staged       # what IS going in
@@ -12,4 +12,5 @@
 #list li {
   padding: 0.5rem 0;
-  border-bottom: 1px solid #ddd;
+  border-bottom: 1px solid #e5e7eb;
+  cursor: pointer;
 }
git diff HEAD           # the total
@@ -12,4 +12,6 @@
 #list li {
   padding: 0.5rem 0;
-  border-bottom: 1px solid #ddd;
+  border-bottom: 1px solid #e5e7eb;
+  cursor: pointer;
+  transition: opacity 0.2s;
 }

Three questions, three precise answers. The MM from git status warns you that something odd is going on; git diff tells you exactly what.

  1. git diff HEAD: everything that has changed

git diff HEAD

It compares your working tree with the last commit, ignoring the staging area. It answers "what have I touched since the last commit?", which is the question you ask yourself coming back from lunch or picking up work in the morning.

There is nothing special about HEAD here: it is simply a reference to a commit, and git diff accepts any of them. These variants are just as valid:

git diff HEAD~1        # against the commit before the last one
git diff HEAD~5        # against the one five commits back
git diff 4e7f2a9       # against a specific commit

The HEAD~n notation and the other ways of referring to a commit are covered in detail in Viewing Commit History.

  1. Reading the unified diff format step by step

The format git diff produces is called unified diff and has been a computing standard since the eighties. You will see it in Git, in GitHub and GitLab code reviews, in email patches and in the output of dozens of tools. Being able to read it is a transferable skill.

Let us dissect a complete output, line by line. Ana has modified app.js:

diff --git a/app.js b/app.js
index 7b2e8f1..3c9d4a2 100644
--- a/app.js
+++ b/app.js
@@ -28,8 +28,12 @@ function renderList() {
   list.innerHTML = '';
   for (const task of tasks) {
     const li = document.createElement('li');
     li.textContent = task.text;
-    li.className = 'task';
+    li.className = task.done ? 'task done' : 'task';
+    li.addEventListener('click', function () {
+      task.done = !task.done;
+      renderList();
+    });
     list.appendChild(li);
   }
 }

Line 1: the header

diff --git a/app.js b/app.js

It signals the start of a file comparison. a/ is the old version and b/ the new one; they are conventional prefixes, not real directories. In a rename you would see different names on each side.

If the diff covers several files, you will see one of these lines per file: it is the separator that tells you where each block begins.

Line 2: the object identifiers

index 7b2e8f1..3c9d4a2 100644

The abbreviated hashes of the old and new blobs, and the file mode (100644 = a normal file). This connects straight back to The Git Data Model: the diff is not an entity stored in Git, it is something Git computes on the fly by comparing two blobs. If the mode changed — say, on making a file executable — you would see old mode and new mode on separate lines.

Lines 3 and 4: the file markers

--- a/app.js
+++ b/app.js

--- marks the old version and +++ the new one. That is why, in the body of the diff, - means "it was in the old version" and + means "it is in the new one".

Two special cases clear a lot up:

--- /dev/null          ← the file is new: it did not exist before
+++ b/new.js
--- a/old.js
+++ /dev/null          ← the file has been deleted: it no longer exists

Line 5: the hunk header (@@)

@@ -28,8 +28,12 @@ function renderList() {

This is the line that takes most getting used to and the one that carries most information. You read it like this:

@@ -<old start line>,<no. of old lines> +<new start line>,<no. of new lines> @@ <context>

Applied to the example:

Part Value Meaning
-28,8 old In the old version this block starts at line 28 and spans 8 lines
+28,12 new In the new version it starts at line 28 and spans 12 lines
function renderList() { context The function or section the change sits in

The numbers tell you everything: 8 lines have become 12, so the block has grown by 4. And sure enough, counting the body: 7 context lines, 1 removed and 5 added. That is how you verify any @@ header:

  • Old lines = context + removed → 7 + 1 = 8.
  • New lines = context + added → 7 + 5 = 12.

The text at the end is not a line of the file: Git extracts it by searching upwards for the last line that looks like a function or section declaration. It helps you get your bearings when the diff is long. It can be tuned per language with .gitattributes, which we cover in File Attributes with .gitattributes.

When the number of lines is 1, it is left out: @@ -12 +12 @@ means "one line on each side".

The body: the lines of the hunk

Every line in the body begins with a character telling you its nature:

First character Means
(space) Context: the line exists identically in both versions
- Removed: it was in the old version, it is gone
+ Added: it was not there before, it is now
\ A special note, almost always \ No newline at end of file

Applied to our example:

   list.innerHTML = '';                             ← context
   for (const task of tasks) {                      ← context
     const li = document.createElement('li');       ← context
     li.textContent = task.text;                    ← context
-    li.className = 'task';                         ← REMOVED
+    li.className = task.done ? 'task done' : 'task';        ← ADDED
+    li.addEventListener('click', function () {     ← ADDED
+      task.done = !task.done;                      ← ADDED
+      renderList();                                ← ADDED
+    });                                            ← ADDED
     list.appendChild(li);                          ← context

Two important observations:

  1. Git has no notion of "modified lines". A modification is always represented as a removal followed by an addition. That is why the first pair of lines shows up as - and + even though conceptually it is "the same line, changed".
  2. The context lines are there for a reason. By default Git shows 3 lines of context on each side of the change, so you can place it. You adjust it with -U<n>:
git diff -U0        # no context: only the changed lines
git diff -U10       # ten lines of context on each side

The detail of \ No newline at end of file

-const version = 1;
\ No newline at end of file
+const version = 2;

It means that the file does not end with a line break. That is a POSIX convention many tools take for granted, and its absence produces noisy diffs: if somebody adds the trailing break, every nearby line can show up as changed. Setting your editor to always add the final break avoids that noise.

A quick thought exercise

Before moving on, read this hunk and answer: how many lines did the block have before, and how many does it have now?

@@ -45,5 +45,3 @@ function updateCounter() {
   const pending = tasks.filter(t => !t.done).length;
-  console.log('DEBUG pending:', pending);
-  console.log('DEBUG total:', tasks.length);
   document.querySelector('#counter').textContent = pending;
 }

Five before, three after: two debugging lines have been removed. This is exactly the kind of find that justifies reviewing the diff before committing.

  1. Comparing specific commits

git diff also compares any two points in the history:

git diff 1a4c8d6 4e7f2a9

It shows everything that changed between those two commits, aggregated into a single diff. It makes no difference how many commits sit in between: it compares the two end states.

Equivalent and much used forms:

# What the last commit introduced
git diff HEAD~1 HEAD

# The changes of the last three commits, together
git diff HEAD~3 HEAD

# From a commit up to the current state of the disk
git diff 1a4c8d6

That last one deserves a note: given a single argument, git diff compares that commit with your working tree, not with HEAD. It is the same logic as git diff HEAD.

Seeing what one particular commit introduced

To inspect a single commit, the idiomatic way is:

git show 4e7f2a9

which shows the metadata (author, date, message) and the diff. That is the subject of the next lesson.

A note about branches

git diff takes branch names exactly as it takes hashes:

git diff main develop

There is also a three-dot notation, git diff main...develop, which compares from the point where the two branches parted ways. Since we work on main throughout this module, we will just note it here: it is developed in module 3.

  1. Limiting the comparison to files or paths

In a change touching fifteen files, reading the whole diff is unmanageable. You narrow it with -- followed by paths:

# One specific file
git diff -- app.js

# Several
git diff -- app.js styles.css

# A whole directory
git diff -- src/

# A pattern
git diff -- "*.css"

The double dash -- separates options from paths. It is optional when there is no ambiguity, so git diff app.js works just as well. It becomes mandatory when a file name could be mistaken for a branch or commit name:

git diff -- main       # the FILE called main
git diff main          # the BRANCH called main

Without the --, Git would try to read main as a reference and, if the branch existed, you would get something completely different from what you asked for. Getting into the habit of putting -- before paths is a good one.

It combines with everything above:

git diff --staged -- app.js
git diff HEAD~3 HEAD -- styles.css

  1. Options that make the output readable

--stat: the summary

git diff --stat HEAD~3 HEAD
 app.js      | 27 +++++++++++++++++++++-----
 styles.css  |  9 ++++++++-
 index.html  |  3 ++-
 3 files changed, 33 insertions(+), 6 deletions(-)

Each line shows the file, the total number of lines affected and a proportional bar of + (additions) and - (deletions). It is the first view worth looking at when facing a large change: it tells you where to look next.

Variants:

git diff --shortstat HEAD~3 HEAD
# → 3 files changed, 33 insertions(+), 6 deletions(-)

git diff --numstat HEAD~3 HEAD
# → 22	5	app.js
# → 8	1	styles.css
# → 2	1	index.html

--numstat gives additions, deletions and name separated by tabs: ideal for processing in scripts.

--name-only and --name-status: the files alone

git diff --name-only HEAD~3 HEAD
app.js
styles.css
index.html
git diff --name-status HEAD~3 HEAD
M	app.js
M	styles.css
A	favicon.svg
D	old.js
R100	README.md	GUIDE.md

--name-status adds a letter per file: M modified, A added, D deleted, R renamed (the number is the similarity percentage: R100 is a rename with no content change).

These options are very practical chained with other commands:

# Count how many files the last commit touched
git diff --name-only HEAD~1 HEAD | wc -l

--word-diff: comparing by words

In a text file (documentation, README, HTML content), changing one word makes the whole line show up as removed and added. It is unreadable:

-The task manager lets you add and list the team's pending tasks.
+The task manager lets you add, list and delete the team's pending tasks.

With --word-diff the real change jumps out:

git diff --word-diff -- README.md
The task manager lets you [-add and list-]{+add, list and delete+} the team's pending tasks.

What was removed goes between [- -] and what was added between {+ +}. With colour in the terminal it is clearer still.

Variants:

git diff --word-diff=color      # colour only, no markers
git diff --color-words          # the short equivalent

It is indispensable for reviewing text and very useful in CSS and HTML.

-w: ignoring whitespace

An automatic reformat, a switch of indentation from tabs to spaces or an editor setting can produce a two-hundred-line diff where the real change is two lines. The whitespace family of options solves it:

Option Effect
-w, --ignore-all-space Ignores all whitespace, wherever it is
-b, --ignore-space-change Ignores changes in the amount of whitespace, not its appearance or removal
--ignore-space-at-eol Ignores whitespace at the end of a line
--ignore-blank-lines Ignores blank lines added or removed
git diff -w

A word of warning: -w is for reading, not for deciding. If the file is indentation-sensitive (Python, YAML, a Makefile), hiding the spacing changes may hide a real bug from you. Use it to locate the substantive change and then go back to the full diff.

Other useful options

# Detect moved code and show it in a different colour
git diff --color-moved

# Differences between characters rather than words
git diff --word-diff-regex=.

# Force colour even when the output goes to a file or a pipe
git diff --color=always

# Show binary files as a binary difference too
git diff --binary

# Higher-quality comparison (the patience algorithm)
git diff --patience

--color-moved is a little-known gem: when you refactor by moving a block from one place to another, it visually distinguishes "this has moved" from "this is new".

  1. git difftool: comparing with a visual tool

For large diffs, or for people who get on better with a two-column view, Git can hand the job over to an external tool:

git difftool
git difftool --staged
git difftool HEAD~1 HEAD -- app.js

It accepts exactly the same arguments as git diff.

Seeing which tools you have available

git difftool --tool-help
'git difftool --tool=<tool>' may be set to one of the following:
		vimdiff
		vimdiff2
		nvimdiff

The following tools are valid, but not currently available:
		araxis
		bc
		kdiff3
		meld
		opendiff
		vscode
		...

Setting it up

For Visual Studio Code, which is what Ana uses:

git config --global diff.tool vscode
git config --global difftool.vscode.cmd 'code --wait --diff "$LOCAL" "$REMOTE"'

For Meld (cross-platform, free and very clear):

git config --global diff.tool meld

For macOS's opendiff, which suits Bruno:

git config --global diff.tool opendiff

And one setting almost everybody ends up adding:

git config --global difftool.prompt false

Without it, Git asks before opening each file, which is exhausting when the change affects ten of them.

The $LOCAL and $REMOTE variables in that configuration are temporary files Git creates with each version and hands to the tool. All of this is stored in ~/.gitconfig through the mechanism we saw in Configuring Git:

[diff]
	tool = vscode
[difftool]
	prompt = false
[difftool "vscode"]
	cmd = code --wait --diff "$LOCAL" "$REMOTE"

Terminal or visual tool?

git diff in the terminal git difftool
Speed Instant Opens a window per file
Small changes Perfect Overkill
Large refactorings Hard to follow Much clearer
Use in scripts Yes No
Works over SSH with no desktop Yes No

The practical recommendation: use git diff as your default tool — it is faster and always there — and keep difftool for the big changes.

Worth a separate mention: there is also git mergetool, the equivalent for resolving merge conflicts. It is configured along the same lines and we cover it in Resolving Merge Conflicts.

  1. The habit of reviewing before committing

Everything above condenses into a thirty-second routine worth making automatic:

git status -s              # which files are in play?
git diff                   # what have I left unstaged?
git add -p                 # stage with judgement
git diff --staged          # is this EXACTLY what I want to commit?
git commit -m "..."

The genuinely valuable step is the second to last. What it usually catches:

  • Debugging leftovers: console.log, print, breakpoints.
  • Credentials or test URLs.
  • Commented-out blocks of code you meant to delete.
  • Accidental changes from the editor's autoformatting in files you were not touching.
  • Files that slipped in through a hasty git add -A.
  • Changes you thought you had made and had not.

If you would rather not run a separate command, the -v option of git commit includes the diff in the message template:

git commit -v

And to leave it switched on permanently:

git config --global commit.verbose true

It is probably the best effort-to-benefit setting in the whole of Git.

Common Mistakes and Tips

  • Running git diff after git add and concluding there are no changes. Empty output means "the disk matches the staging area". What you want is git diff --staged.
  • Expecting to see new files in git diff. An untracked file has no previous version, so it does not appear. That is what git status is for. If you want to see it, stage it first (git add -N file records it as empty and makes its contents show up in the diff).
  • Reading the @@ header backwards. The first number belongs to the old version and the second to the new one. Mixing them up leads to misreading the direction of the change.
  • Thinking Git stores diffs. It does not: it stores snapshots and computes the diff when you ask for it, as we saw in the data model. That is why you can compare any two commits, however far apart they are.
  • Overusing -w. In indentation-sensitive languages, ignoring whitespace can hide the very bug you are hunting. It is a reading tool, not a final-review one.
  • Not using -- before an ambiguous path. If you have a file with the same name as a branch, git diff main will not do what you think.
  • Reviewing a 40-file change through the full diff. Start with --stat to see the map, and drop into the detail only where it matters.
  • Tip: set aliases for whatever you use most: git config --global alias.d diff and git config --global alias.ds "diff --staged".
  • Tip: if the output of git diff seems to "trap" you in the pager, remember that you move through it with the arrow keys or the space bar and leave with q. To turn it off just once, git --no-pager diff.
  • Tip: git diff --stat right after git add -A is the quickest way to spot that you have added something you did not mean to.

Exercises

Exercise 1: Three areas, three different diffs

Set up this scenario and reason out your answer before running each command:

mkdir -p ~/practice/diffs && cd ~/practice/diffs
git init
cat > app.js <<'EOF'
const tasks = [];

function addTask(text) {
  tasks.push(text);
}
EOF
git add app.js && git commit -m "Initial version"

# Change 1: staged
sed -i 's/tasks.push(text);/tasks.push({ text: text, done: false });/' app.js
git add app.js

# Change 2: NOT staged
echo "" >> app.js
echo "function countTasks() { return tasks.length; }" >> app.js
  1. What will git status -s show?
  2. What will git diff show? How many + lines will it have?
  3. What will git diff --staged show?
  4. What will git diff HEAD show?
  5. If you commit now with no further git add, what will the file in the history contain?
  6. Check your answers by running the lot.

Exercise 2: Reading a diff without running it

Interpret this output and answer the questions:

diff --git a/styles.css b/styles.css
index a1b2c3d..d4e5f6a 100644
--- a/styles.css
+++ b/styles.css
@@ -8,10 +8,8 @@ body {
 h1 {
   font-size: 1.8rem;
-  color: #333;
-  margin-bottom: 1rem;
+  color: #1f2937;
 }

-.hidden { display: none; }
 #counter {
   color: #6b7280;
 }
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..7a8b9c0
--- /dev/null
+++ b/config.js
@@ -0,0 +1,4 @@
+const API_URL = 'https://api.example.com';
+const TIMEOUT = 5000;
+const DEBUG_KEY = 'test-1234';
+export { API_URL, TIMEOUT, DEBUG_KEY };
  1. How many files does this change affect, and what happens to each one?
  2. In styles.css, how many lines have been removed and how many added? How does the total number of lines in the block change?
  3. What exactly does --- /dev/null mean in the second file?
  4. What does index 0000000..7a8b9c0 mean?
  5. Is there anything in this diff you should not commit? Explain why, and what you would do.
  6. Write the command that would show only the per-file summary of this change.

Exercise 3: Finding the real change among the noise

Simulate a file being reindented at the same time as a line of logic changes:

mkdir -p ~/practice/noise && cd ~/practice/noise
git init
cat > app.js <<'EOF'
function calculateTotal(tasks) {
    let total = 0;
    for (const task of tasks) {
        if (!task.done) {
            total = total + 1;
        }
    }
    return total;
}
EOF
git add app.js && git commit -m "Initial version"

# Reindent from 4 spaces to 2 AND change the condition
cat > app.js <<'EOF'
function calculateTotal(tasks) {
  let total = 0;
  for (const task of tasks) {
    if (!task.done && !task.archived) {
      total = total + 1;
    }
  }
  return total;
}
EOF
  1. Run git diff and count how many lines show up as changed.
  2. Use the right option to see only the logic change.
  3. Explain why the results of the two commands are so different.
  4. What would have been better to do from the outset so that the history stayed readable? Write the correct sequence of commands.
  5. Run --word-diff on the same change and comment on whether it adds anything here.

Solutions

Solution to Exercise 1

1. git status -s:

MM app.js

There is a staged version (change 1) and the disk differs from it (change 2).

2. git diff — the unstaged part. It shows change 2 only:

@@ -3,3 +3,5 @@ const tasks = [];
 function addTask(text) {
   tasks.push({ text: text, done: false });
 }
+
+function countTasks() { return tasks.length; }

Two + lines: the blank line and the function. The tasks.push({...}) line shows up as context, not as an addition, because the staging area already holds it that way.

3. git diff --staged — what is going in. Change 1 only:

@@ -1,5 +1,5 @@
 const tasks = [];

 function addTask(text) {
-  tasks.push(text);
+  tasks.push({ text: text, done: false });
 }

4. git diff HEAD — the total, both changes together:

@@ -1,5 +1,7 @@
 const tasks = [];

 function addTask(text) {
-  tasks.push(text);
+  tasks.push({ text: text, done: false });
 }
+
+function countTasks() { return tasks.length; }

5. Committing with no further git add would bring in change 1 only. The file in the history would have the push with the object, but not the countTasks function, which would still be pending in the working tree.

Checking:

git commit -m "Store the done state of each task"
git show HEAD:app.js
const tasks = [];

function addTask(text) {
  tasks.push({ text: text, done: false });
}
git status -s
# →  M app.js      ← change 2 is still there, uncommitted

Solution to Exercise 2

1. Two files:

  • styles.css: modified (there is a --- a/ +++ b/ pair with the same name and mode 100644).
  • config.js: a new file, as new file mode 100644 and --- /dev/null show.

2. In styles.css: 3 lines removed (color: #333;, margin-bottom: 1rem; and .hidden { display: none; }) and 1 added (color: #1f2937;).

The count matches the @@ -8,10 +8,8 @@ header: the block goes from 10 to 8 lines, losing 2 (−3 +1 = −2). And you can verify it by counting the hunk's lines by hand:

  • Old version = context lines + - lines → 7 context + 3 removed = 10.
  • New version = context lines + + lines → 7 context + 1 added = 8.

This checking exercise is the best way to make sure you have understood the @@ header: the two numbers must always tally with what you see in the body.

3. --- /dev/null says that the old version of the file does not exist: it is a new file. /dev/null is the "null device" of Unix systems, and here it stands for "nothing at all". The mirror case, +++ /dev/null, would mean a deletion.

4. index 0000000..7a8b9c0 are the hashes of the old and new blobs. The left-hand one is all zeros because there is no old blob: consistent with the file being new.

5. Yes, there is a problem: DEBUG_KEY = 'test-1234' in config.js. Test key or not, it is a secret in the code, and committing it leaves it permanently in the history for anybody who clones the repository. Harmless as it may be today, it sets a bad precedent and it is exactly the mechanism by which real keys end up leaking.

What I would do:

# Take the constant out of the file and move it to an environment variable
# or to an ignored local configuration file.
# Then unstage config.js:
git restore --staged config.js

# Edit it, and stage only the legitimate part again:
git add -p config.js

If config.js should not be versioned at all:

git restore --staged config.js
echo "config.js" >> .gitignore
git add .gitignore

6. The per-file summary:

git diff --stat
 config.js   | 4 ++++
 styles.css  | 4 +---
 2 files changed, 5 insertions(+), 3 deletions(-)

Solution to Exercise 3

1. The full diff:

git diff
@@ -1,9 +1,9 @@
 function calculateTotal(tasks) {
-    let total = 0;
-    for (const task of tasks) {
-        if (!task.done) {
-            total = total + 1;
-        }
-    }
-    return total;
+  let total = 0;
+  for (const task of tasks) {
+    if (!task.done && !task.archived) {
+      total = total + 1;
+    }
+  }
+  return total;
 }

Fourteen lines show up as changed (7 removed and 7 added), when the real change in behaviour is just one.

2. Seeing the logic alone:

git diff -w
@@ -1,7 +1,7 @@
 function calculateTotal(tasks) {
   let total = 0;
   for (const task of tasks) {
-        if (!task.done) {
+    if (!task.done && !task.archived) {
     total = total + 1;
     }
   }

Now there is only one -/+ pair: the condition. The indentation of the other lines is ignored, so the real change stands on its own.

3. Why the difference is so large. Git compares whole lines, character by character. Swapping four spaces for two at the start of a line makes it, as far as Git is concerned, a different line: the old one is removed and the new one added. Since the reformat touches all seven lines of the function body, all seven show up as changed and the logic change is lost among them. -w tells Git to normalise whitespace before comparing, leaving only the real difference.

4. The right approach from the outset: two separate commits. Mixing reformatting with functional changes is a well-known bad practice, because it makes the change impossible to review and ruins git blame for the whole block.

# Commit 1: the reformat ONLY
# (reindent the file, without touching the logic)
git add app.js
git commit -m "Reindent calculateTotal to 2 spaces"

# Commit 2: the behaviour change ONLY
# (add the !task.archived condition)
git add app.js
git commit -m "Exclude archived tasks from the total"

The diff of the second commit is two lines and can be reviewed in five seconds. On top of that, if the logic change has to be undone tomorrow, it comes out without dragging the reformat with it.

If the two changes are already mixed on disk, git add -p with the e option lets you separate them, though in this particular case — where each line contains both changes at once — redoing the work in two steps would be more practical.

5. With --word-diff:

git diff --word-diff
@@ -1,9 +1,9 @@
 function calculateTotal(tasks) {
   let total = 0;
   for (const task of tasks) {
     if (!task.done[- -]{+ && !task.archived +}) {

It helps a good deal: comparing by words rather than by lines all but removes the noise from the reindentation, and the change to the condition is pinpointed surgically. In text changes and in cases like this one, --word-diff and -w complement each other well:

git diff -w --word-diff

Conclusion

You can now read changes before they enter the project's story. To recap:

  • git diff always compares two areas, and the form you use decides which: git diff (disk ↔ staging), git diff --staged (staging ↔ HEAD) and git diff HEAD (disk ↔ HEAD, that is, the total).
  • Empty output from git diff does not mean "I have changed nothing": nearly always it means you have already staged everything.
  • No git diff shows untracked files. That is what git status is for.
  • The unified diff format has a fixed structure: a diff --git header, blob hashes, ---/+++ markers, @@ -a,b +c,d @@ hunk headers with their context, and context, removed (-) and added (+) lines. Git does not represent "modified lines": a modification is a removal plus an addition.
  • Any two commits can be compared (git diff <sha1> <sha2>) and narrowed by path with -- <path>, using -- whenever the name could be ambiguous.
  • The options change readability radically: --stat for the overall map, --name-only/--name-status for the list, --word-diff for text and -w for separating the real change from spacing noise.
  • git difftool hands over to a visual tool and takes the same arguments; it pays off on large changes.
  • The habit that matters: git diff --staged before every git commit, or simply commit.verbose = true.

With git status you know where you are, with git diff you know what you have changed, and with git add/git commit you decide what gets recorded. One piece of the basic cycle is missing: looking backwards.

A history is only worth as much as your ability to query it. After two hundred commits, how do you find when a function was introduced? Who touched styles.css last week? In which commit did that line you swear you wrote disappear?

In the next lesson, Viewing Commit History, we will look at git log with all its formats and filters, custom formats with --pretty, the "pickaxe" search that finds when a particular piece of text appeared or vanished, git show for inspecting a commit, and the various ways of referring to a commit — HEAD, HEAD~3, HEAD^ — that we have been using in passing and that are worth understanding properly.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved