Embercode
✕
Git and GitHubLesson 14 of 16

40 min read

Reviewing changes with diff and staging hunks

Why this matters

A commit is a saved note in the project history. Anything in it stays there until someone deliberately removes it. Before you commit, you want to know exactly what you changed, and git diff shows you that in a few seconds.

Often one file holds two unrelated changes. You fixed a typo, and you also started a new feature in the same file. If you commit the whole file, one commit tells two stories. If the feature turns out to be wrong later, you cannot revert it without also reverting the typo fix. git add -p solves this. It lets you choose which parts of a file go into the next commit.

Lesson 3 showed git diff once, for one added line, and explained the file header lines. This lesson covers three things that lesson did not: comparing the three places a change can sit, reading each part of a hunk (a hunk is one block of changed lines in a diff), and staging part of a file.

Three places a change can sit

Lesson 3 named the three places where Git keeps your work. Here they are again, briefly:

  • The working directory is the set of files on disk, as you edit them.
  • The staging area is the list of changes that the next commit will include.
  • HEAD is the most recent commit, the last saved snapshot on your branch.

Each git diff command compares two of these places. Once you know which two, you know what the output means.

CommandComparesShows
git diffstaging area and working directoryedits you have not staged yet
git diff --stagedHEAD and staging areachanges the next commit will include
git diff HEADHEAD and working directoryevery edit since the last commit, staged or not

git diff --staged and git diff --cached are the same command. Use the one you find easier to read.

A worked example with three edits

Start with a file named recipe.txt, already committed, with this content:

1 Banana bread
2
3 Ingredients:
4 2 ripe bananas
5 1 egg
6 100 g flour
7
8 Method:
9 Mash the bananas.
10 Mix in the egg.
11 Add the flour.
12 Bake for 60 minutes.
13 Serve warm.

The numbers at the start of each line are only for this lesson. They are not in the file. Now make three changes, one after another. After each change, look at the three diffs.

Change 1. Edit line 4 to say 3 ripe bananas. Do not stage it.

  • git diff shows the change to line 4.
  • git diff --staged shows nothing, because nothing is staged.
  • git diff HEAD shows the change to line 4.

Change 2. Run git add recipe.txt. The change to line 4 is now staged.

  • git diff shows nothing, because the staging area and the working directory now agree.
  • git diff --staged shows the change to line 4.
  • git diff HEAD shows the change to line 4.

Change 3. Edit line 6 to say 150 g flour. Do not stage it.

  • git diff shows only the change to line 6.
  • git diff --staged shows only the change to line 4.
  • git diff HEAD shows both changes.

Change 3 is the trap that catches most beginners. The plain git diff shows only line 6, and it is easy to forget that line 4 is already on the list for the next commit. Run git commit -m "Use three bananas" now, and the commit will contain line 4 but not line 6. The file on disk will still have both changes, and git status --short will show the file as MM, which lesson 3 described.

The habit that prevents this: before every commit, run git diff --staged to see what will be saved, then run git diff to see what will be left out.

Why git diff shows less after you stage

After git add, the plain git diff looks empty, and new users often think their change disappeared. It did not. Staging moved it into the list for the next commit, and git diff only compares the working directory with that list. git diff --staged is the command that shows the list.

Reading a hunk, line by line

The output of git diff is made of hunks. Each hunk starts with a header line that begins with two @@ signs. The lines after the header show the old text and the new text of one region of the file.

The examples in this section use Python's difflib module. It is not Git, but it writes unified diffs (the standard text format that Git also uses), and its hunk header lines have the same shape as Git's. The file header lines at the top differ from Git's, which lesson 3 covered.

Here is a short script that edits two places in the banana bread text, far apart, and prints the diff:

Python · editable

It prints:

--- a/recipe.txt
+++ b/recipe.txt
@@ -1,7 +1,7 @@
 Banana bread
 
 Ingredients:
-2 ripe bananas
+3 ripe bananas
 1 egg
 100 g flour
 
@@ -9,5 +9,5 @@
 Mash the bananas.
 Mix in the egg.
 Add the flour.
-Bake for 60 minutes.
+Bake for 50 minutes.
 Serve warm.

Each line in a hunk starts with one of three characters:

  • A space means the line did not change. These lines are called context. They help you find where the change sits.
  • A minus sign means the line was removed. In a diff of two versions, this is the old text.
  • A plus sign means the line was added. This is the new text.

Git prints the same three markers, and it adds a few header lines before the first hunk. Lesson 3 showed those lines: diff --git, which names the file, and index, which holds fingerprints of the two versions.

Now read the first header, @@ -1,7 +1,7 @@, one part at a time:

  • -1,7 describes the old side (the version before the change). It starts at line 1 and covers 7 lines.
  • +1,7 describes the new side (the version after the change). It starts at line 1 and covers 7 lines.

Count the lines in the hunk to check. It has 7 lines starting with a space or a minus sign, which are the old lines, and 7 lines starting with a space or a plus sign, which are the new lines. The change is at line 4. Git shows three lines of context before and after it by default, so the hunk starts at line 1 and ends at line 7.

The second header, @@ -9,5 +9,5 @@, starts at line 9. The change is at line 12, and three lines of context come before it: lines 9, 10 and 11. Line 13 is the one context line after the change, so the hunk is five lines long. Git writes the count after the comma. When a side has only one line, Git leaves the count out, so you may see @@ -4 +4 @@ instead of @@ -4,1 +4,1 @@.

Here is the key idea: the numbers in the header tell you where to look in the file, and the markers tell you what changed. To read a hunk, find the line numbers first, then compare each minus line with the plus line below it.

Close changes share one hunk

Now change line 4 and line 6, which have only one unchanged line between them:

Python · editable

It prints:

--- a/recipe.txt
+++ b/recipe.txt
@@ -1,9 +1,9 @@
 Banana bread
 
 Ingredients:
-2 ripe bananas
+3 ripe bananas
 1 egg
-100 g flour
+150 g flour
 
 Method:
 Mash the bananas.

There is only one hunk now. The two changes are close together, so their context overlaps, and the unchanged line 1 egg sits in the middle of the hunk. This matters when you stage: git add -p works hunk by hunk, so two changes inside one hunk cannot be separated by a plain yes or no. The next section covers how to split them.

Git's -U option sets the number of context lines. git diff -U1 shows one line of context instead of three. That makes hunks shorter and easier to scan on a large file, and it makes close changes more likely to separate into different hunks.

Staging part of a file with git add -p

git add -p means "add, in patch mode". Git goes through the changes in one file, one hunk at a time, and asks you about each hunk. Your answer decides whether that hunk goes into the staging area. The file on disk is never changed. Only the list for the next commit changes.

Take a Python file greeting.py that is already committed. Suppose you made two edits:

  • A bug fix near the top of the file, in the greet function.
  • A new function shout added at the bottom of the file.

The two edits are far apart, so Git shows them as two hunks. The steps:

  1. Run git add -p greeting.py.
  2. Git shows the first hunk, which is the bug fix, and asks what to do with it. Answer y.
  3. Git shows the second hunk, which is the new function, and asks again. Answer n, to leave it out.
  4. Run git diff --staged. It shows only the bug fix.
  5. Run git diff. It shows only the new function, because that edit is still unstaged.
  6. Run git commit -m "Fix whitespace in greet".

After the commit, the new function is still in the working file, and it is still an unstaged change. You can commit it later with its own message.

The prompt for each hunk looks like this:

Stage this hunk [y,n,q,a,d,s,e,p,P,?]?

The exact list of letters can change a little between Git versions, and it changes when a hunk can be split. Type ? to see the list your version uses. These are the answers you will use most:

AnswerWhat it does
yStage this hunk.
nLeave this hunk unstaged. It stays as an edit in the working file.
qQuit. Hunks after this one are left unstaged.
aStage this hunk and every hunk after it in the file.
dLeave this hunk and every hunk after it unstaged.
sSplit this hunk into smaller hunks. Git offers this only when it can split.
eEdit the hunk by hand in your editor. This is for advanced cases.
?Show all the answers.

Splitting a hunk with s

Suppose the bug fix and a second, unrelated fix sit close together, with two unchanged lines between them. Git puts them in one hunk. Type s, and Git splits the hunk at the unchanged lines into two smaller hunks. You can then answer y for one and n for the other.

Git cannot split a hunk when two changes are right next to each other, with no unchanged line between them. In that case the s answer is not offered. Use e to edit the hunk. In your editor, you see the lines with a plus or minus sign at the start. Delete the plus line of the change you do not want, or change its minus line back to a space, so that the hunk contains only the change you want to stage. Then save and close the editor. Be careful with this: a broken hunk can make Git refuse to apply it.

Checking the result before you commit

Partial staging is easy to get wrong, so check it every time. Two commands do the check:

  • git diff --staged shows exactly what the next commit will contain.
  • git diff shows what will be left out.

Read both. If the staged part is not what you wanted, fix it with git restore --staged -p greeting.py, which takes hunks off the list. The file on disk is not changed by that command.

git commit -p also works. It shows the hunks first, and you choose which ones to include, then the commit is made in the same step. Many people prefer git add -p, because you can check the staged result before you write the commit message.

Common mistakes

1. Committing without checking git diff --staged. This is the change 3 trap from the worked example. The commit has less than you expected, and git status shows the file as MM. Fix it with git add for the rest, or run git commit --amend if the commit has not been shared (lesson 7).

2. Pressing a by reflex. The a answer stages every remaining hunk in the file. It behaves like pressing y again and again. If you wanted only one hunk, press y or n for each hunk instead.

3. Answering n to every hunk, then committing. Nothing is staged, so the commit has nothing in it. Git says:

no changes added to commit (use "git add" and/or "git commit -a")

Staging is the step that puts changes on the list. Run git diff --staged before you commit, and make sure it shows the lines you want.

4. Confusing git restore -p with git restore --staged -p. Both take -p, but they do different things. git restore --staged -p takes hunks off the staging area and keeps the edits. git restore -p asks you whether to discard each hunk from the working file. Discarded edits are not stored anywhere in Git, so they cannot be recovered. If you are not sure, run git diff first and read what you are about to lose.

5. Typing the wrong file name. Git stops with this message when the name does not match a file:

fatal: pathspec 'greeting.pyy' did not match any files

With git add -p, the same typo prints No changes. and asks nothing.

"Pathspec" is Git's word for a file name or pattern. Use ls to find the right name.

6. Expecting git diff to show staged changes. After git add -p, the plain git diff shows only the unstaged hunks. The staged ones are in git diff --staged. This is the same point as change 3, and it causes the same confusion.

Practice

Do these in a fresh practice folder, with a file you have already committed. Try each one before you read the answer.

Exercise 1 (easy). Change one line in a committed file, but do not stage it. Which diff command shows the change, and which one shows nothing?

Show the answer

git diff shows the change, because it compares the working directory with the staging area. git diff --staged shows nothing, because nothing has been staged.

Exercise 2 (easy). Stage the change from exercise 1 with git add, then edit a different line. Predict what git diff shows, then what git diff --staged shows. Check your prediction.

Show the answer

git diff shows only the second edit, the one that is not staged. git diff --staged shows only the first edit, the one on the list for the next commit. git diff HEAD shows both.

Exercise 3 (medium). A hunk header reads @@ -20,4 +20,6 @@. How many lines does the old side cover, how many does the new side cover, and where does the hunk start?

Show the answer

The hunk starts at line 20 on both sides. The old side covers 4 lines and the new side covers 6 lines, so this hunk adds two lines net. Four lines are context or removed lines, and six lines are context or added lines.

Exercise 4 (medium). Make two edits to a committed file, far apart, so that Git shows two hunks. Use git add -p to stage only the first edit, then check with git diff --staged and git diff. Which answers do you give to Git, in order?

Show the answer

Answer y to the first hunk and n to the second. Then git diff --staged shows only the first edit, and git diff shows only the second. Commit, and the history contains just the first edit.

Exercise 5 (hard). Two changes sit directly next to each other, with no unchanged line between them. Type git add -p and look at the prompt. Why is the s answer missing, and what can you do instead?

Show the answer

Git can split a hunk only at unchanged lines, and there are none between these two changes, so s is not offered. Use e to edit the hunk in your editor. Delete the plus line of the change you do not want to stage, or change its minus line back to a space, then save. Check the result with git diff --staged. If editing feels risky, answer y to stage both changes together, and write a message that names both.

Exercise 6 (hard). You staged two hunks, but you now want to take one of them off the list and keep the edit in the file. Which command, and which answer do you give?

Show the answer

Run git restore --staged -p file-name. Answer y to the hunk you want to take off the list, and n to the others. The edit stays in the file, and the hunk is no longer staged.

Summary

  • git diff compares the staging area with the working directory, git diff --staged compares HEAD with the staging area, and git diff HEAD compares HEAD with the working directory.
  • A hunk starts with an @@ header. The numbers give the start line and the count on each side, and the lines below use a space for context, a minus sign for removed lines and a plus sign for added lines.
  • Close changes share one hunk, and a hunk can be split only at unchanged lines.
  • git add -p stages a file hunk by hunk. Answer y to stage a hunk, n to leave it out, and s to split it when Git allows.
  • Check git diff --staged before each commit, because the commit contains only what is on that list.
  • git restore -p discards edits from the working file for good. Use git restore --staged -p to take hunks off the list instead.

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.