Here’s a small mystery that confuses most people learning Git. You change a file, run git add, then make one more edit. git status now lists the same file twice, once as “Changes to be committed” and once as “Changes not staged for commit.” You commit, and the last edit isn’t in the commit.
Nothing is broken. Git keeps up to three versions of every file at once, and the commands you use every day each move content between them. Once you can picture those three places, status, diff, add, commit, restore and reset stop being a list of incantations. Interviewers ask “what is the staging area?” for exactly that reason: the answer shows whether you have the model or just the commands.
Three places a file can live
- The working tree is the directory you edit, the files your editor and compiler see.
- The index (also called the staging area or cache) is a single file,
.git/index, that describes the next commit: for every tracked path, which content it will contain. - HEAD is the commit you currently have checked out, the last snapshot recorded on your branch.
Right after a checkout or commit, all three agree. Editing changes the working tree. git add copies a file’s current content into the index. git commit turns the index into a new commit and moves HEAD to it. Content flows working tree → index → HEAD, one deliberate step at a time.
Watching a file sit in all three
In a test repository, tax.py was committed with TAX_RATE = 0.20. Change it to 0.21, stage it, then add a second line without staging:
printf 'TAX_RATE = 0.21\n' > tax.py
git add tax.py
printf 'TAX_RATE = 0.21\nROUNDING = "half-up"\n' > tax.py$ git status --short
MM tax.pyThe two letters are the two comparisons. The first column compares HEAD with the index (M: the staged version differs from the last commit). The second compares the index with the working tree (M: your file differs from what’s staged). Each comparison has its own diff command:
$ git diff # index -> working tree: what you haven't staged
@@ -1 +1,2 @@
TAX_RATE = 0.21
+ROUNDING = "half-up"
$ git diff --cached # HEAD -> index: what the next commit will contain
-TAX_RATE = 0.20
+TAX_RATE = 0.21git diff HEAD compares HEAD directly with the working tree, showing both changes together. A common mistake is running plain git diff after git add, seeing nothing, and concluding there are no changes. Plain git diff only shows what isn’t staged yet.
The index holds content, not a reference to your file
git add doesn’t mark the file “to be included.” It hashes the file’s current content, stores it as a blob in the object database, and records that blob’s hash in the index:
$ git ls-files -s
100644 48017946bcb60647a9a5fdf9e91d989384064727 0 tax.py
$ git hash-object tax.py
b161b28440f78e0fd79e8e0d1257d28051052f3aThe index points at blob 4801794, which is TAX_RATE = 0.21 alone. The file on disk now hashes to b161b28, a different blob. The index has a frozen copy from the moment you ran git add. That’s why the later edit isn’t included until you add again.
What a commit records
$ git commit -m "Raise tax rate to 21%"
$ git status --short
M tax.py
$ git show HEAD:tax.py
TAX_RATE = 0.21The commit took the index as it was, so the committed tax.py has only the rate change, and the ROUNDING line is still an unstaged modification. Look inside the commit:
$ git cat-file -p HEAD
tree 0a92de8bcd570872d71ef5f842f8aadc6f40ca21
parent 49ed5edb761db7cd5d6eaa195015e32b0d37594c
author Dev <dev@example.com> 1790848800 +0000
committer Dev <dev@example.com> 1790848800 +0000
Raise tax rate to 21%
$ git cat-file -p HEAD^{tree}
100644 blob 48017946bcb60647a9a5fdf9e91d989384064727 tax.pyThe commit points to a tree, and the tree lists every path with its blob, the same 4801794 blob that was in the index. A commit is a full snapshot of the project, not a diff. When git show displays a commit as a diff, it computes that on the fly by comparing the commit’s tree with its parent’s tree. This is the misconception interviewers most often probe: “Git stores diffs.” It stores snapshots, and saves space by reusing identical blobs between commits and by compressing objects into pack files.
Why have a staging area at all?
Other version control systems commit whatever’s changed. Git’s extra step exists so you can shape commits deliberately. You can stage half a file’s changes with git add -p to split unrelated edits into separate commits. You can stage a fix, keep debug logging unstaged in the same file, and commit only the fix. And during a merge, the index is where Git records conflicted paths and where you mark them resolved with git add.
If you don’t need that control, git commit -a stages modifications to tracked files and commits them in one go. It doesn’t include new files, which surprises people:
$ echo 'TAX_RATE = 0.22' > tax.py
$ echo "# notes" > notes.md
$ git commit -qam "Rate 22"
$ git status --short
?? notes.mdtax.py was committed; the new notes.md wasn’t, because it was never tracked.
Moving things back
The undo commands are easiest to remember as “copy from one place to another”:
git restore --staged tax.pycopies HEAD’s version into the index. It unstages the change. Your working file is untouched.git restore tax.pycopies the index’s version into the working tree. It discards unstaged edits, and they’re gone; Git never stored them.git restore --source=HEAD --staged --worktree tax.pyresets both from the last commit.
In the test repository, with the full two-line file staged:
$ git restore --staged tax.py
$ git status --short
M tax.py
$ git restore tax.py
$ cat tax.py
TAX_RATE = 0.21The first command unstaged the change (the M moved from the first column to the second). The second threw away the working-tree edit, leaving the committed content. Older guides use git reset HEAD <file> for the first and git checkout -- <file> for the second; they do the same thing. git reset --soft, --mixed and --hard are the same idea applied to the whole tree plus moving the branch pointer: soft moves only HEAD, mixed also resets the index, and hard resets the working tree too.
Interview practice
A developer runs git add ., then notices config/local.yaml was included by mistake and edits it back to the committed values, then runs git commit -m "Add search filters". Code review shows config/local.yaml changed in the commit with their local database password. Explain why, and how they should have handled it. The commit hasn’t been pushed.
Answer
git add . copied the file’s content, password included, into the index. Editing the file afterwards only changed the working tree; the index still held the staged version. git commit records the index, so the password went into the commit, even though the file on disk looked clean. git status would have shown MM config/local.yaml before the commit, and git diff --cached would have shown the password.
The right move before committing was git restore --staged config/local.yaml, which resets that path in the index to HEAD’s version, and then adding it to .gitignore if it shouldn’t be tracked at all. Since the commit isn’t pushed, they can fix it now: git restore --source=HEAD~1 --staged config/local.yaml to put the previous version in the index, then git commit --amend --no-edit to rewrite the commit without the change. Then git diff HEAD~1 HEAD -- config/local.yaml should print nothing. Because the password was only ever local, rotating it is optional, but if there’s any chance it was pushed or copied somewhere, rotate it.