datarekha
Git June 7, 2026

Git's three trees: the mental model that makes Git click

Understand git's working directory, staging area, and HEAD so every git command finally makes sense — no more mystery, no more fear.

11 min read · by datarekha · gitversion controlstaging areaindexmental model

Most people’s first few weeks with Git feel like defusing a bomb in the dark. You type commands from Stack Overflow, something explodes anyway, and you quietly rm -rf the folder and re-clone. If that resonates, you are not bad at Git. You just haven’t been shown the map.

The map is three trees. Once you see them, every command stops being a magic incantation and becomes an obvious next step. This post draws that map, then walks every command you use daily across it.

For installation and initial configuration, see /git/install-and-config/. For branching concepts, see /git/branches/. The rest of the /git/ series builds on the model introduced here.

What “tree” actually means here

In Git’s documentation the word tree means a structure that holds a snapshot of your files at a point in time. It is not a binary search tree. Think of each tree as a complete copy of your project’s file system, frozen at a particular moment.

Git tracks three of these at all times:

  1. Working directory — the actual files on your disk that you can open and edit in any editor.
  2. Staging area (also called the index) — a proposed snapshot, sitting between your edits and the permanent record.
  3. HEAD — a pointer to the branch you have checked out, which itself points to the most recent commit on that branch.

Every command you already know is just a controlled movement of content between those three locations.

Diagram: the three trees and the commands that move between them

Working Directoryfiles on diskyou edit thesetracked + untrackedStaging Area(index)proposed snapshotcurated before commitHEAD→ branch → commitlast saved snapshotpermanent historygit addgit commitgit restore / git checkoutgit restore —staged
The three Git trees and the primary commands that move content between them.

Working directory: where you live

Your working directory is just the folder on your hard drive. Open it in VS Code, rename a file, delete a line — you are editing the working directory. Git can see those edits but has not recorded them anywhere permanent yet.

git status tells you what is different between your working directory and the other two trees. Files that differ from the staging area appear as “Changes not staged for commit.” Files that differ from HEAD but match staging appear as “Changes to be committed.”

Staging area: crafting the next snapshot

The staging area — also called the index — is Git’s most misunderstood feature. It exists so you can decide exactly what goes into the next commit before you commit it.

Suppose you fixed a bug and refactored a function in the same afternoon. Shipping those as one commit would muddy your history. The staging area lets you add only the bug-fix files with git add bugfix.py, inspect what you have with git diff --staged, then commit just that chunk with a focused message. The refactor stays in your working directory until you are ready for its own commit.

git add <file> copies the current state of a file from the working directory into the staging area. git add -p lets you stage individual hunks (sections) of a file, giving you surgical control. See the /glossary/ for a refresher on terms like “hunk” and “tracked file.”

git diff with no arguments shows what is in the working directory but not yet staged. git diff --staged (or git diff --cached) shows what is staged but not yet committed. Confusing these two is a classic source of “but I thought I committed that” moments.

HEAD: the permanent record

HEAD is a pointer. It points to the branch you have checked out (say, main). That branch is itself a pointer to a specific commit. A commit is a snapshot, not a diff. Every commit stores the full state of every tracked file, identified by a SHA-1 hash like a3f9c12. Git computes diffs on the fly when you run git log -p or git diff, but what is stored is always the complete picture.

When you run git commit, Git:

  1. Takes everything in the staging area.
  2. Wraps it in a commit object that records your name, timestamp, parent commit hash, and the snapshot.
  3. Writes that object to the repository with a new hash.
  4. Advances HEAD (and the current branch pointer) to the new commit.

The staging area is then identical to HEAD again. Your working directory is unchanged.

git restore <file> (available since Git 2.23) copies the staged version of a file back into the working directory, discarding your unsaved edits. git restore --staged <file> removes a file from the staging area without touching the working directory — the opposite of git add.

git checkout -- <file> is the older equivalent of git restore <file>. It works the same way but the double-dash syntax is cryptic and easy to mistype. Prefer git restore in any Git version that supports it.

For branch-level navigation, git checkout <branchname> or git switch <branchname> moves HEAD to the tip of another branch and updates both the staging area and working directory to match. See /git/branches/ for the full branching mental model.

git reset: the three levels

git reset is the command most people are afraid of, and the three-tree model makes it completely transparent. Every form of reset moves HEAD (and its branch pointer) to a different commit. The three flags control how far the ripple spreads.

git reset --soft <commit> moves HEAD to <commit>. The staging area and working directory are left exactly as they were. Your changes are still staged, ready to be committed again. Use this to rewrite a commit message or to squash several recent commits into one.

git reset --mixed <commit> (the default when you omit a flag) moves HEAD and also resets the staging area to match the target commit. Your working directory is unchanged, but all changes that were staged are now unstaged. Use this when you want to recommit with a different split across files.

git reset --hard <commit> moves HEAD, resets the staging area, and overwrites the working directory. Every change since <commit> is gone from all three trees.

Diagram: reset —soft vs —mixed vs —hard

Working DirStaging AreaHEAD—softunchangedunchanged• moved—mixedunchanged• reset to HEAD• moved—hard• reset to HEAD• reset to HEAD• movedaffected (moved or overwritten)unchanged
Which trees each variant of git reset moves. Only —hard touches the working directory.

Putting it all together: a typical workflow

Here is what the three-tree model looks like in a normal coding session:

# You edit src/app.py in the working directory.

# See what changed across all three trees:
git status

# See working directory vs staging area:
git diff

# Stage just the file you care about:
git add src/app.py

# See what is staged vs HEAD:
git diff --staged

# Commit the staged snapshot to HEAD:
git commit -m "fix: handle null input in parse_record"

# Oops, typo in the message &#8212; amend before pushing:
# (rewrites the last commit; don't do this after pushing to shared branches)
git commit --amend -m "fix: handle None input in parse_record"

And when you want to undo:

# Unstage a file (staging &#8594; back to just working directory):
git restore --staged src/app.py

# Discard working directory changes for a file (dangerous, no undo):
git restore src/app.py

# Move HEAD back one commit, keep changes staged:
git reset --soft HEAD~1

# Move HEAD back one commit, unstage changes (default):
git reset HEAD~1

# Move HEAD back one commit, discard all changes (dangerous):
git reset --hard HEAD~1

Why the staging area exists

New developers often try to skip staging entirely by always typing git add . followed immediately by git commit. That works, but it throws away the most powerful feature Git has.

The staging area lets you tell a story. Good commit history reads like a series of deliberate decisions: “add database schema,” “add migration script,” “add controller layer.” Bad commit history reads like a save-game autosave: “wip,” “more stuff,” “fix,” “asdf.” The staging area is the tool that makes deliberate commits possible even when your actual working session was messy.

Use git add -p to interactively stage portions of files. Combine it with git diff --staged to review before committing. Over time this discipline pays off enormously in code review, bisecting bugs, and reverting safely. More Git commands and workflows are covered throughout /git/.

Frequently asked questions

Q: Is HEAD always the latest commit?

HEAD is always the latest commit on whichever branch you have checked out. If you check out a specific commit hash directly with git checkout <hash>, Git enters “detached HEAD” state, where HEAD points to a commit rather than to a branch. New commits you make in that state are not attached to any branch and can be lost when you switch away. Use git switch -c <newbranch> to attach them to a named branch.

Q: What exactly is stored in the staging area? Is it a copy of my files?

The staging area is stored as a binary file called .git/index. It does not store full copies of your files; it stores references (SHA-1 blob hashes) to the file content Git has already compressed and stored in its object database. When you git add a file, Git writes the content to the object store and records the reference in the index. This is why staging is fast even for large files.

Q: What is the difference between git diff and git diff HEAD?

git diff (no arguments) compares the working directory to the staging area. git diff HEAD compares the working directory to the last commit, skipping the staging area entirely. If you have staged changes that match HEAD, git diff will show nothing for those files but git diff HEAD will still show them.

Q: Can I recover from git reset --hard?

Often yes, within a window. Git keeps a log of every position HEAD has pointed to in the file .git/logs/HEAD, viewable with git reflog. Find the commit hash from before the reset and git checkout <hash> to inspect it, then git branch recovery-branch <hash> to save it. This works as long as Git has not garbage-collected the old objects, which by default takes at least 30 days. Recovery is not guaranteed, which is why the warning above stands.

Skip to content