LEARN · PHASE 02 · LESSON 12

Commit Is a Photograph, Not a Diff

You can choose exactly what lands in a commit — instead of discovering after

Type
Learn
Prerequisites
Phase 00, Lesson 2
Time
~35 minutes

Learning Objectives

  • Put two deliberate files into one commit and leave everything else out
  • Say what a commit actually stores, and where the staging area fits
  • Read any commit's identity back out of its hash
  • Write a commit message a stranger could act on

The Problem

An agent refactored a file for you. It was right about nine files and wrong about one. You ran git add -A && git commit -m "fix parser" because that is the command you know.

Now the commit holds nine files of unrelated refactoring and one fix. Reviewing it means reading 400 changed lines to find the three that matter. Reverting it means losing the good eight. And there is no way to split it afterwards without rewriting history you have already pushed.

Nothing about the situation was hard. The command was just too blunt for a mixed bag. One bad commit poisons every commit after it, because nobody can cherry-pick your fix without dragging the refactor along.


The Concept

Lesson 2 established that a commit is a frozen copy of the whole project. This lesson adds the part that gives you control: a commit is not the diff. It is the whole tree at one moment, and you choose the moment.

Three places, in order:

flowchart LR
    E[Editor / agent wrote files] --> WT[Working tree<br/>live folder on disk]
    WT -->|git add| IDX[Staging area<br/>the index<br/>your draft]
    IDX -->|git commit| C[Commit<br/>frozen full tree]
    C -->|git diff| WT
Mermaid source — renders as a diagram where mermaid.js is available

Working tree — the live folder. Everything on disk.

Staging area (the index) — a draft. Not history yet, not the folder yet. This is the part almost nobody uses deliberately, and it is the whole point of this lesson.

Commit — a frozen full tree, identified by a hash of its contents.

git add src/parser.js test/parser.test.js   # only these two
git commit -m "fix parser: handle empty input"

You edited eleven files. Two went in. The other nine are still uncommitted and still yours.

A commit stores a tree, not a delta

You have heard "git stores diffs to save space." That is true of the packfile on disk and false of what a commit is. A commit is a complete snapshot. When two commits are stored together, git finds and stores the parts they share.

This matters because it means:

  • A commit can be read on its own. git show on any commit shows the whole tree at that moment.
  • Restoring one file from any commit needs no history walking.
  • Merging two commits is comparing two complete trees, not applying patches.

The hash is the content

The commit hash is derived from what is in the commit — the tree, the parent, the author, the message, the timestamp. Change any of it, the hash changes. Two people who commit identical content with identical messages and timestamps get the same hash. Change one character of the message and it is a different commit.

git cat-file -p HEAD    # what is actually inside a commit: tree, parent, author, message
git rev-parse HEAD      # the hash

git cat-file -p is the command that turns "a commit is a photograph" from a phrase into something you can read on screen.

Why this matters for agents

An agent's output is one blob of mixed change. If you commit the blob, you have made the blob permanent and unreviewable. The staging area is where you split the blob: the fix in, the refactor out, the unrelated edit left for later.

That is a two-minute operation done before the commit. After the commit it is git rebase -i, which is Lesson 14's territory and much worse.

The reframe: git add is not "save my work", it is "put this exact work

in the next photograph" — the photograph always shows the whole project, so

what you leave out of the staging area is what you keep out of history.


Build It

1. Watch the staging area hold a draft

mkdir -p ~/git-drill && cd ~/git-drill
git init
printf 'one\n' > a.txt
printf 'one\n' > b.txt
printf 'one\n' > c.txt
git add a.txt
git status

a.txt is staged. b.txt and c.txt are not. The commit that comes next will contain a.txt, plus the other two files exactly as they were when the repo started — which right now is "untracked", so they will not be in it at all.

✅ You pass when git status lists a.txt under changes to be committed and b.txt, c.txt under untracked files.

2. Commit a partial set

git add b.txt
git commit -m "add a and b"
git status
git show --stat HEAD

The commit has two files. c.txt is still untracked and the working tree is not clean.

✅ You pass when git show --stat HEAD lists exactly a.txt and b.txt, and c.txt still appears in git status.

3. Take a commit apart

git cat-file -p HEAD

You get a tree hash, a parent line (absent here — first commit), author, timestamp, and the message. The commit names its contents.

✅ You pass when you can point at the tree hash line and say that the commit does not store changes, it stores a complete tree at that hash.

4. See that the message is part of the identity

git rev-parse HEAD
git commit --amend -m "add a and b (typo fix)"
git rev-parse HEAD

The hash changed. Nothing about the files changed.

✅ You pass when the two hashes differ and you can say why, in one sentence.

5. Stage a hunk, not a file

Make a mess in one file: change two unrelated lines.

printf 'one\ntwo\nthree\n' > a.txt
printf 'one\nCHANGED\nthree\n' > a.txt   # two edits in the same file
git diff                # both changes, unstaged
git add -p a.txt        # answer y for the first hunk, n for the second
git diff --cached       # only the first change is staged

✅ You pass when git diff --cached shows exactly one of the two changes.


Use It

Command / conceptWhen you reach for it
git add <file>Putting a known-good file into the next commit
git add -pSplitting one file's changes into separate commits
git statusBefore every commit. Tells staged from unstaged
git diff --cachedSeeing the actual draft of the next commit
git commit -mFreezing the draft
git cat-file -p <sha>Seeing what a commit really contains
git show --stat <sha>What a commit touched, without reading it
Commit messageWhat a stranger needs to decide whether to revert this

Exercises

  1. Take any real change you have lying around — a fix, a config edit, anything. Stage only the files that belong to that one thought. Commit them. Leave the rest uncommitted. ✅ You pass when git show --stat HEAD lists only the files you meant, and git status still shows your other changes as uncommitted.
  1. Write a commit message for a change someone would need to evaluate in six months without asking you. Then amend it. Record both hashes. ✅ You pass when you have two different hashes from two different messages, and a message that names the what and the why, not the how.
  1. Use git add -p to make two commits out of changes in one file. Compare git log --oneline -2 against a single commit of both changes. ✅ You pass when the two-commit log has two entries and reverting either one alone is possible without reverting the other.

Key Terms

TermWhat people sayWhat it actually means
commit"saving my changes"A frozen full tree with a message and a parent. Content-addressed.
diff"the commit"The difference between two trees. Useful to read; never what is stored.
staging area / index"the thing before the commit"A draft tree. git add edits the draft; git commit freezes it.
git add -p"partial staging"Choose change-by-change inside one file instead of all or nothing.
commit hash"the commit id"A digest of the commit's contents. Change any byte, including the message, and it changes.
--amend"fixing the last commit"Replaces the last commit. Fine locally, disruptive once pushed.
git revert"undo"New commit that undoes an old one. History stays honest.

What this lesson skipped

Sign commits. Nothing in this course requires them, and they are friction on Windows setups.

Rewriting a commit after pushing. rebase -i and reset on shared history. That is Lesson 14, and Lesson 16 for when you have already done it wrong.

Commit templates and hooks. commit-msg hooks automate message format. Real and worth adding once your messages are consistently good — after this lesson, not before.

Check yourself

4 questions. Answer before you reveal — recognising the right option is not the same as being able to produce it.

  1. An agent edited six files: one real bug fix, three unrelated renames, and two files it touched while exploring. You want the fix in history and the rest out. What do you do?

    1. Commit all six, then use git revert later on the unwanted four
    2. git add only the fix file, then commit
    3. Commit all six so the history records what happened, and be more careful next time
    4. git add -A, git commit, then git reset --soft HEAD~1 to separate them afterwards
    Show answer

    B. git add only the fix file, then commit

    The staging area is the filter, and using it before the commit costs seconds. Reverting (A) leaves the renames in history forever and adds four junk commits; each one is a thing a reader has to skip. Committing everything (C) is the instinct this lesson exists to break — the commit always shows the whole project, so whatever is staged is what becomes history. Resetting afterwards (D) rewrites history you probably already pushed, which is Lesson 14's mess, not a step.

  2. You amend a commit only to fix a typo in its message. The files in it are byte-identical. What happened to the commit?

    1. It became a new commit — its hash is derived from its contents, including the message
    2. It kept its hash because the files did not change
    3. It became a dangling commit that git garbage-collects on the next run
    4. It was replaced by a merge of the old and new commit
    Show answer

    A. It became a new commit — its hash is derived from its contents, including the message

    The hash covers the tree, the parent, the author, the timestamp and the message, so changing any of it produces a different commit. Option B would mean two different messages could share an identity, which defeats the point of a content address. Option C confuses this with the old commit being unreachable after a reset — amend does not do that. Option D is not a thing git does anywhere.

  3. A colleague says the repo is bloated because 'git stores every change as a diff'. Why is the concern wrong?

    1. Git compresses diffs with zlib, so the storage cost is already negligible
    2. A commit stores a complete tree; shared parts are found and stored once when objects are packed
    3. Diff-based storage was removed in git 2.0
    4. Only the final state of each branch is stored, so intermediate commits cost nothing
    Show answer

    B. A commit stores a complete tree; shared parts are found and stored once when objects are packed

    The commit itself names a full tree, not a patch — which is why git show works on any commit in isolation and why restoring one file needs no history walk. Packing is where similarity between trees is exploited. Compression (A) is real but it is a different mechanism and it does not make the claim true. Diffs-as-storage were never removed (C), and old commits are not free (D) — that is how git gc has work to do at all.

  4. You edited ten lines in one file, two of which belong to a bug fix and eight to an unrelated cleanup. The eight come first in the file. How do you commit just the fix?

    1. Commit the file whole, then revert the cleanup in a second commit
    2. git add -p the file and take only the hunk containing the fix
    3. Split the file into two files, commit one, then move it back
    4. Stage the whole file, then use git checkout -- <file> to undo the cleanup in the working tree
    Show answer

    B. git add -p the file and take only the hunk containing the fix

    git add -p walks the changes in a file and lets you take or skip each one, so the commit draft contains only the fix. Reverting afterwards (A) leaves both in history and adds noise. Splitting files (C) restructures the repo to serve your commit message — a much bigger change than the problem. Option D destroys the cleanup in the working tree without removing it from the staged draft; the commit would still contain it, and the cleanup work would be gone from disk.