Almost every “Git is confusing” moment in the first month traces back to one thing: not knowing that a change lives in one of three places, and that most commands move it between them.
Topic 1: The Three Places
The working tree — the files on disk, the ones your editor opens. Git watches them but does not own them.
The index (also called the staging area, or the cache) — a real binary file at .git/index that holds the proposed next commit. It is a complete tree, not a list of pending changes.
HEAD — a ref pointing at the commit your branch is currently on. It is what “the last commit” means.
git add copies from working tree to index. git commit writes the index into a new commit object and moves HEAD. That is the entire core loop, and every other command in this module is a variation on moving between these three.
Topic 2: Why the Index Exists
The staging area is the part beginners want removed and experienced users would fight to keep. It buys one thing: the commit you make does not have to be everything you changed.
You fixed a bug, and while you were in there you also renamed a variable, added a debug print and reformatted a function. Without an index you get one commit containing all four. With an index you commit the fix alone, then decide about the rest.
git add -p # walk the change hunk by hunk: y/n/s/e
git add -p src/api.js # same, for one file
git add -p is the single highest-value command in this lesson. It offers each hunk in turn: y stage it, n skip it, s split it into smaller hunks, e edit the hunk by hand. Ten minutes with it turns “I’ll just commit everything” into commits that a reviewer can actually read — and reviewable commits are what make git bisect and git blame useful later.
The cost of skipping this shows up months later, in a bisect that lands on a 400-line commit titled “various fixes”. At that point the commit tells you nothing, and you are reduced to reading the whole diff.
Topic 3: The Four Diffs
This is the table that resolves the most common confusion in Git:
| Command | Compares | Answers |
|---|---|---|
git diff | working tree ↔ index | What have I changed but not staged? |
git diff --staged | index ↔ HEAD | What will my next commit contain? |
git diff HEAD | working tree ↔ HEAD | Everything since the last commit |
git diff main..feature | two commits | What does this branch add? |
--cached is an older synonym for --staged; both appear in documentation and they are identical.
”git diff shows nothing but I definitely changed something” is almost always because you already ran git add. The change moved to the index, and git diff compares against the index. Use git diff HEAD when you want “everything I have done since the last commit” regardless of staging.
Topic 4: Reading git status Properly
git status is not a list of files. It is a report on three trees at once:
On branch feature/retry-logic
Your branch is ahead of 'origin/feature/retry-logic' by 2 commits.
Changes to be committed: ← index vs HEAD (this is your next commit)
modified: src/client.go
Changes not staged for commit: ← working tree vs index
modified: src/client.go
deleted: src/old.go
Untracked files: ← in neither; Git is ignoring them entirely
notes.md
src/client.go appearing in both sections is not a bug — it means you staged one version and then edited the file again. The commit will contain the staged version, not what is on disk right now. Predicting that correctly is the point of the hands-on task, and getting it wrong is how a half-finished change reaches a shared branch.
git status -s gives the two-column short form, which is worth learning because it appears everywhere:
M src/client.go ← modified, not staged (right column = working tree)
M src/api.go ← modified and staged (left column = index)
MM src/db.go ← staged, then modified again
?? notes.md ← untracked
A src/new.go ← newly added to the index
D src/old.go ← deleted
Untracked is a real state, not a warning. Git will never commit, stash (by default), or preserve an untracked file. A git clean -fd deletes them with no recovery — the reflog cannot help, because Git never recorded them.
Topic 5: Walking a Change Backwards
Each tree has a command that pulls a change back one step. Modern Git split these out of the overloaded git checkout:
# Working tree → discard, restore from the index
git restore src/api.js
# Index → unstage, keep the file as it is on disk
git restore --staged src/api.js
# Both → back to the last commit, discarding everything
git restore --source=HEAD --staged --worktree src/api.js
# An older version of one file, into your working tree
git restore --source=HEAD~3 src/api.js
git checkout -- file and git reset HEAD file still work and appear in every older tutorial. restore and switch exist because checkout did too many unrelated jobs, and the split makes the intent legible in a script or a review.
git restore without --staged is destructive and silent. It overwrites your file with the staged version, and there is no reflog for uncommitted work. Before discarding anything you might want back, commit it on a scratch branch or stash it — both cost seconds.
Topic 6: What Git Ignores, and Why It Still Tracks It
.gitignore prevents untracked files from being added. It has no effect on a file that is already tracked — a fact that generates a support question in every team, eventually.
# Already committed by accident? Stop tracking it, keep it on disk:
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "stop tracking .env"
Note what that does not do: the file’s earlier contents remain in history forever. Removing a committed secret is a history-rewrite operation, covered in its own lesson — and the first step there is always to rotate the credential, not to rewrite anything.
Which ignore file to use for what:
| File | Scope | For |
|---|---|---|
.gitignore in the repo | Committed, shared | Build output, dependencies, anything generated |
.git/info/exclude | Local, not shared | Your own scratch files |
core.excludesFile (global) | Every repo you clone | Editor and OS junk — .DS_Store, .idea/, *.swp |
Putting .DS_Store in a project’s .gitignore is a small act of imposing your operating system on everyone else’s repository. It belongs in your global excludes.
git check-ignore -v path/to/file # which rule is ignoring this, and where
That command answers “why is my file not being added” instantly, and it names the file and line number of the offending pattern.
Try it yourself: stage a file, edit it again, then run git commit and inspect the result with git show. Confirm it contains the staged version rather than what is on disk. This one experiment prevents a whole category of “I committed the wrong thing” incidents.
Common mistake: using git add . reflexively from the repository root. It stages whatever happens to be there — a debug file, a local config, a 40 MB test fixture, a .env you meant to ignore. Staging deliberately, with git add -p or explicit paths, takes seconds and is the difference between a commit that reads as one idea and a commit nobody can review.