How Git Works: Working Tree, Index, HEAD and Objects
Git is built from a small number of parts that fit together in a way you can hold in your head. Once you can name those parts and describe how data moves between them, Git’s commands stop being a list to memorise and become predictable consequences of the design.
This lesson is the architectural centre of the Git Fundamentals pillar. Later lessons zoom in on individual pieces; this one shows the whole machine.
The short answer
Section titled “The short answer”Git is two things stacked on each other:
- A content-addressable object database. It stores immutable objects — file contents, directory listings, commits — each named by a hash of its own content.
- A system of references. Human-readable names like
mainandHEADpoint into that database so you never have to type a 40-character hash.
Everything else — add, commit, branch, merge, checkout — is a way of creating objects in that
database or moving references around in it.
The three states
Section titled “The three states”At any moment, a file in a Git project exists in up to three places. Nearly every confusing Git message becomes clear once you can say which of the three you are looking at.
Three stacked areas. At the top, the working tree: the files on disk you edit. In the middle, the index or staging area: the content prepared for the next commit. At the bottom, the repository: the object database of committed snapshots inside the .git directory. Arrows flow downward: git add moves content from the working tree to the index, and git commit moves content from the index into the repository. Arrows flow upward too: git restore --staged copies from the repository back to the index, and git restore or git checkout copies from the index or repository back to the working tree.
The working tree
Section titled “The working tree”The working tree — also called the working directory — is the ordinary set of files and folders in your project. It is what your editor opens and what your program runs. Git checks a snapshot out into the working tree, you modify it, and Git notices the difference.
The working tree holds exactly one version of each file. It has no memory. Overwrite a file with no commit and no stash, and Git cannot recover it, because Git never saw it.
Lesson 8 covers the working tree in detail.
The index
Section titled “The index”The index is a single binary file, .git/index, that holds the complete content listing for your
next commit. Every tracked file has an entry recording its path, its permissions bits, and the
object ID of its content.
When you run git add, Git writes the file’s current content into the object database and updates that
file’s index entry to point at the new content. When you run git commit, Git turns the index into a
tree structure and records it.
The index is why you can commit a subset of your changes: it is a scratch area where you assemble exactly the snapshot you intend to record.
Lesson 9 covers the index in detail.
The repository
Section titled “The repository”The repository is the .git directory. It holds the object database — every commit, every directory
listing, every version of every file you have committed — plus the references that name entry points
into it, plus configuration.
Objects in the repository are immutable. Git adds to it; it does not edit what is there.
Lesson 12 tours the .git directory.
The object database
Section titled “The object database”Underneath the three states is one uniform storage mechanism. Git stores four kinds of object, and every one of them is named by the SHA-1 hash of its own content plus a short type header.
| Object | Stores | Points to |
|---|---|---|
| Blob | The contents of one file | Nothing |
| Tree | A directory listing: names, modes, and object IDs | Blobs and other trees |
| Commit | A snapshot pointer plus metadata | One tree, and zero or more parent commits |
| Tag (annotated) | A named, described pointer to an object | Usually a commit |
Two properties follow from naming objects by their content, and both are load-bearing.
Identical content is stored once. Two files with the same bytes produce the same hash, so they are the same blob — whatever their names, wherever they live in the tree, however many commits contain them. A file that does not change across a hundred commits is stored once.
Objects are immutable and self-verifying. Change any byte and the hash changes, which means you now have a different object rather than a modified one. Git can detect corruption by rehashing, and history cannot be silently altered: editing an old commit changes its ID, which changes every descendant’s ID.
Lesson 11 explores the object database hands-on.
References: how names point at commits
Section titled “References: how names point at commits”Nobody types a936f7ce6532d0e18aba39d1081bde0ee51895fd. References solve that.
A reference (or ref) is a small file under .git/refs/ whose contents are an object ID. A branch is
exactly this: refs/heads/main is a file containing the ID of the commit at the tip of main.
HEAD is the reference that tells Git where you are. Normally it does not contain an object ID at all; it contains a pointer to another ref:
ref: refs/heads/mainThat indirection is the whole mechanism behind “the current branch”. Read HEAD to find the branch; read the branch to find the commit; read the commit to find the tree; read the tree to find the files.
A left-to-right chain. HEAD points to the branch ref refs/heads/main. The branch ref points to a commit with the abbreviated ID 4ff2767. The commit points to a tree. The tree points to two entries: a blob for README.md and a subtree named src, which in turn points to a blob for greet.py.
Because a branch is a file containing one ID, “creating a branch” writes 41 bytes and “moving a branch forward” overwrites them. That is the entire cost, and it is why Git users branch freely.
Lesson 10 covers HEAD and its notation.
What git add actually does
Section titled “What git add actually does”git add is routinely described as “telling Git about a file”. That description is wrong in a way that
causes real confusion later.
-
Git reads the file’s current content from the working tree.
Not the filename. The bytes, as they exist at the moment you run the command.
-
Git computes the content’s object ID and writes a blob.
The content is compressed and stored in the object database under its hash. If a blob with that exact content already exists, nothing new is written — it is already there.
-
Git updates the file’s entry in the index.
The index entry for that path now records the new blob’s object ID, along with the file mode and filesystem metadata Git uses to detect later changes cheaply.
The consequence is the thing to remember:
You can see the mechanism directly. git hash-object computes the object ID Git would use for some
content, without touching your repository:
printf 'hello\n' | git hash-object --stdinWhat it doesComputes and prints the object ID Git would assign to the content arriving on standard input.
Why we run itIt demonstrates that object IDs are derived purely from content — nothing about the filename, the timestamp or your repository is involved.
Expected resultA 40-character hexadecimal ID. Running it again with identical input produces an identical ID, on any machine, in any repository.
ce013625030ba8dba906f756967f9e9ca394464aThat value is universal. Any Git repository storing the exact bytes hello\n stores them as a blob
with that ID.
What git commit actually does
Section titled “What git commit actually does”Committing is the operation that turns the flat index into a permanent, structured snapshot.
-
Git builds tree objects from the index.
The index is a flat list of paths like
src/greet.py. A commit needs a hierarchy. Git constructs one tree object per directory, from the leaves upward: a tree forsrclistinggreet.py, then a root tree listingREADME.mdand thesrctree. -
Git creates a commit object.
It records the root tree’s ID, the ID of the current commit as its parent, the author and committer identities with timestamps, and your commit message.
-
Git updates the current branch to point at the new commit.
It reads HEAD to find which branch you are on, then writes the new commit’s ID into that branch’s ref file.
-
Git records the move in the reflog.
A local log of where the branch and HEAD have pointed, which is what makes many mistakes recoverable.
Note what did not happen: your working tree was not touched, and your files were not moved. Commit is purely a repository operation. It reads the index and writes objects and a ref.
Note also that the parent link is what makes history a graph rather than a list. A normal commit has one parent. The very first commit in a repository has none — it is a root commit. A merge commit has two or more.
What checkout and switch actually do
Section titled “What checkout and switch actually do”Moving between branches or commits runs the pipeline in reverse.
-
Git resolves the target to a commit, then reads that commit’s root tree.
-
Git updates the index to match that tree — every path, every mode, every blob ID.
-
Git updates the working tree to match the index: writing changed files, creating files that should exist and removing files that should not.
-
Git moves HEAD. With
git switch main, HEAD becomesref: refs/heads/main. Withgit switch --detach <commit>, HEAD holds the commit ID directly — the detached HEAD state.
Because steps 2 and 3 overwrite your files, Git refuses to switch if doing so would destroy uncommitted changes it cannot preserve. That refusal is a safety feature, not an obstacle:
error: Your local changes to the following files would be overwritten by checkout: app.pyPlease commit your changes or stash them before you switch branches.A complete walkthrough
Section titled “A complete walkthrough”Here is one edit travelling through the whole system, with what Git does at each step.
Start. The working tree, index and HEAD commit all agree. git status reports
nothing to commit, working tree clean.
You edit app.py. Only the working tree changed. The index still holds the old content, and so
does the HEAD commit. git status reports the file under Changes not staged for commit, because the
working tree and the index now disagree.
You run git add app.py. Git writes a new blob for the current content and points the index entry
at it. Now the index and HEAD disagree, while the working tree and index agree. git status reports
the file under Changes to be committed.
You run git commit -m "...". Git builds trees from the index, writes a commit object referencing
the root tree and the previous commit, and moves the branch ref forward. Now all three agree again, and
git status is clean.
The two diff commands map directly onto those disagreements, which is the most practical thing to
take from this lesson:
| Command | Compares |
|---|---|
git diff | Working tree ↔ index — what you have not staged yet |
git diff --staged | Index ↔ HEAD — what will go into the next commit |
git diff HEAD | Working tree ↔ HEAD — everything since the last commit |
If you can name which two of the three states you want to compare, you can always pick the right command.
How history forms a graph
Section titled “How history forms a graph”Because each commit records its parent, commits form a directed acyclic graph — arrows always point backwards in time, and there are no cycles.
A commit graph. Commits A, B and C run left to right along a main line. From commit B, two commits D and E branch upward on a feature line. A merge commit M sits to the right of C, with arrows from both C and E, showing that a merge commit has two parents. The main branch label points at M.
Reading that graph explains several behaviours at once:
- A branch is a label on a node.
mainpoints atM;featurepointed atE. - Merging creates a node with two parents. Both histories remain intact and reachable.
- “Deleting a branch” deletes a label. The commits remain in the object database until Git’s garbage collection removes those that nothing can reach.
- History is append-only in normal use. Commands that appear to change history —
rebase,amend— actually create new commits and move refs to them. The originals are still there, which is why the reflog can rescue you.
Common mistakes
Section titled “Common mistakes”Thinking git add marks a file permanently. It stages content once. Edit again, stage again.
Expecting git commit to include everything you changed. It commits the index. Files you did not
stage are not in it. (git commit -a stages tracked, modified files first — but never untracked ones.)
Reading git diff as “all my changes”. Bare git diff shows only unstaged changes. After staging
everything, it prints nothing, which looks alarming and is correct. Use git diff HEAD for everything.
Treating branches as directories. Switching branches changes the contents of the same directory. Your files do not move.
Assuming a commit is a diff. A commit is a snapshot. Git displays commits as diffs against their parent because that is how humans read them, but that is presentation, not storage.
Mental Model
Section titled “Mental Model”Hold three sentences:
The working tree is what you are editing. The index is the exact snapshot you are preparing. The repository is the permanent history of snapshots.
And two more for the plumbing beneath:
Objects are content named by its own hash, and they never change. References are movable names that point into that pile of objects.
Every Git command you will meet is some combination of: create objects, move refs, or copy content between the three states.
What You Learned
Section titled “What You Learned”- Git is an immutable object database plus a set of movable references.
- Content lives in three states: working tree, index, repository.
- The four object types are blobs, trees, commits and annotated tags.
- Object IDs are hashes of content, so identical content is stored once and history is tamper-evident.
git addwrites a blob and updates an index entry — it stages content, not a filename.git commitbuilds trees from the index, writes a commit object, and moves the current branch ref.checkout/switchruns the pipeline in reverse: commit → tree → index → working tree, then moves HEAD.- Commits form a directed acyclic graph; branches are labels on nodes in that graph.
Try It Yourself
Section titled “Try It Yourself”If Git is installed, this takes two minutes and makes the three states visible. If not, the next three lessons cover installation.
- In a scratch directory, run
git initand create a file with any content. - Run
git statusand note which section the file appears in. - Run
git addon it, thengit statusagain. Which section now? - Before running anything else, edit the file again and predict what
git statuswill show. - Run
git status. The file should appear in both sections — one entry for the staged content, one for the newer unstaged content.
Step 5 is the single most valuable thing to see with your own eyes. It is only surprising if you
believed git add marked a filename.
Next Lesson
Section titled “Next Lesson”You have the model. The next three lessons install Git on each major platform and configure it properly — identity, default branch, editor and credentials — so the rest of the curriculum has a working environment to run in. Pick the one that matches your machine.