Step 01 of 10
How Git works
Build an accurate mental model of Git: snapshots, the object store, refs, HEAD and the three areas every command moves data between.
Most Git confusion comes from memorising commands without knowing what they act on. Git is a small, elegant data structure with a command line wrapped around it, and once you can picture that structure, commands stop being magic spells. In this lesson you will look inside a real repository, see how commits are stored, learn what a branch actually is, and meet the three areas that every everyday command moves data between.
Snapshots, not diffs
Many people imagine that Git stores a list of changes: "line 4 was added, line 9 was removed". It does not. Every commit records a complete snapshot of every tracked file at the moment you committed.
That sounds wasteful, but if a file has not changed between two commits, both snapshots point at the same stored copy of it. Only changed files cost new storage. (Git also compresses objects into "pack files" using deltas internally, but that is an optimisation you never have to think about.)
Why does this matter to you?
- Checking out an old commit is fast and exact: Git just writes that snapshot to disk.
- A diff is not stored; Git computes it on demand by comparing two snapshots, so you can diff any two commits, not only neighbours.
- Each commit stands on its own. Rewriting one commit never corrupts another.
The object store
Everything Git keeps lives in a hidden .git directory at the root of your project. Create a throwaway repository and look:
mkdir recipes && cd recipes
git init
echo "# Recipes" > README.md
git add README.md
git commit -m "Add README"
ls .gitCOMMIT_EDITMSG HEAD branches config description hooks index info logs objects refsThe heart of it is .git/objects, a key–value database. You give it content, it gives you back a key: the SHA-1 hash of that content, a 40-character hexadecimal ID. The same content always produces the same ID, on any machine in the world. This is called content addressing.
You can ask Git to hash something without even committing it:
echo "# Recipes" | git hash-object --stdinae5e354863163528256fab6c692aaada622e83bdRun that on your machine and you will get exactly the same ID. If a single byte of a stored object were ever corrupted, its hash would no longer match and Git would notice.
The four object types
There are only four kinds of object:
| Type | Stores | Analogy |
|---|---|---|
| blob | The contents of one file (no name, no permissions) | A page of text |
| tree | A directory listing: names, modes and the IDs of blobs and sub-trees | A folder |
| commit | A pointer to one root tree, parent commit(s), author, committer, message | A labelled photograph of the folder |
| tag | An annotated tag: a named, signed-or-dated pointer to another object | A sticky note on a photograph |
You can inspect any object with git cat-file -p, which pretty-prints it. Start from the commit that HEAD points at:
git cat-file -p HEADtree a5f17f18f58a4e1ecdb3dd67cf79256d0e47ec79
author Ada Lovelace <[email protected]> 1791705809 +0000
committer Ada Lovelace <[email protected]> 1791705809 +0000
Add READMEFollow the tree:
git cat-file -p a5f17f1100644 blob ae5e354863163528256fab6c692aaada622e83bd README.mdAnd the blob is just your file's bytes:
git cat-file -p ae5e354# RecipesNotice the blob ID is the same one hash-object printed. The file name lives in the tree, not the blob, so two files with identical content are stored once. Also notice you can abbreviate IDs to their first few characters as long as the prefix is unique in your repository.
Commits form a chain
Add a second file and commit again:
printf 'flour\nwater\n' > bread.txt
git add bread.txt
git commit -m "Add bread recipe"
git cat-file -p HEADtree 033a14155e766d61adc46462043bbbdd19058fc2
parent a4a62c4c35659576c52a5b594304b60ebfbea51b
author Ada Lovelace <[email protected]> 1791705809 +0000
committer Ada Lovelace <[email protected]> 1791705809 +0000
Add bread recipeThe new commit has a parent line pointing at the first commit. Its tree lists both files, and the README.md entry reuses the existing blob ae5e354 rather than storing a copy. Drawn out, the store now looks like this:
commit 1d62648 ──parent──▶ commit a4a62c4
│ │
▼ ▼
tree 033a141 tree a5f17f1
├─ README.md ─────┐ └─ README.md ─┐
└─ bread.txt ─┐ │ │
│ └──────▶ blob ae5e354 ◀─┘
▼
blob 400b4abBecause a commit's ID is a hash over its tree, its parent ID, its author and its message, changing anything about an old commit (even fixing a typo in the message) produces a different ID, and every descendant commit must get a new ID too. History in Git is effectively tamper-evident. You will lean on this fact when you learn about rewriting history later in the roadmap.
Refs, branches and HEAD
Forty-character IDs are not friendly, so Git lets you name commits. A name that points at a commit is a ref. Branches are refs stored under .git/refs/heads/:
cat .git/refs/heads/main1d6264841a6f3c961571839444f2df534592393eThat is the whole implementation of a branch: a tiny file containing a commit ID. (Git may later consolidate many refs into a single .git/packed-refs file, so in older repositories the loose file might be missing; git rev-parse main always prints the ID.) Creating a branch writes a 41-byte file. This is why Git branches are so cheap compared with older systems that copied entire directories. When you commit on a branch, Git writes the new commit object and then moves the branch file to point at it.
HEAD answers the question "where am I right now?". Normally it does not hold a commit ID directly; it names the current branch:
cat .git/HEADref: refs/heads/mainSo the chain is: HEAD → main → latest commit → its tree → its blobs. When you make a new commit, Git uses HEAD to find the current commit (which becomes the parent) and to know which branch to advance.
HEAD
│
▼
main
│
▼
a4a62c4 ◀── 1d62648In commands, HEAD is shorthand for "the current commit", HEAD~1 (or HEAD^) for its parent, HEAD~2 for the grandparent, and so on.
The three areas
Git commands move data between three places. Learning these three names is the single most useful thing in this lesson.
- Working tree – the ordinary files and folders you edit in your editor.
- Index (also called the staging area) – a single file,
.git/index, that holds the snapshot you are preparing to commit: a list of paths, each mapped to a blob ID. - Repository – the object store and refs in
.git: every commit you have ever made.
working tree index (staging) repository
┌─────────────┐ add ┌─────────────┐ commit ┌─────────────┐
│ files you │ ────▶ │ next commit │ ───────▶ │ commits, │
│ edit │ │ snapshot │ │ trees, blobs│
└─────────────┘ └─────────────┘ └─────────────┘
▲ │
└────────── switch / restore (write files) ─────┘A commit is made from the index, not from your working tree. git add copies the current content of a file into the object store as a blob and records that blob in the index. git commit turns the index into a tree, wraps it in a commit, and moves the branch.
You can see the index directly:
git ls-files --stage100644 ae5e354863163528256fab6c692aaada622e83bd 0 README.md
100644 400b4ab3d84db71d4249998c7f8fc39899b368fe 0 bread.txtWatching a file in all three areas
Here is the experiment that makes the model click. Edit a file, stage it, then edit it again:
echo "salt" >> bread.txt
git add bread.txt
echo "yeast" >> bread.txt
git statusOn branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: bread.txt
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: bread.txtThe same file appears twice, and that is correct. There are now three versions of bread.txt:
| Area | Contents of bread.txt |
|---|---|
Repository (HEAD) | flour, water |
| Index | flour, water, salt |
| Working tree | flour, water, salt, yeast |
If you commit now, the commit contains "salt" but not "yeast", because only the index is committed. git status is simply a report of the differences: index versus HEAD ("to be committed") and working tree versus index ("not staged").
Why the index exists
Why not just commit whatever is on disk? Because the index lets you shape commits. If you fix a bug and tidy unrelated formatting in the same session, staging lets you commit the fix on its own and the formatting separately, so each commit tells one clear story that is easy to review, search and undo.
Practice
In a scratch directory (not inside an existing project), build a repository by hand and explore it with plumbing commands:
- Create a repository called
zoo, add a fileanimals.txtcontainingcat, and commit it. - Create a sub-directory
birds/withbirds/owl.txtcontainingcat(yes, the same text), stage it and commit. - Use
git cat-file -pto walk fromHEADdown to every blob. How many blob objects exist for the two files, and why? - Append
dogtoanimals.txt, stage it, then appendfox. Predict whatgit statuswill report, then check.
Show solution
mkdir zoo && cd zoo
git init
echo "cat" > animals.txt
git add animals.txt
git commit -m "Add animals"
mkdir birds
echo "cat" > birds/owl.txt
git add birds
git commit -m "Add owl"
git cat-file -p HEAD # shows tree <id> and parent <id>
git cat-file -p 'HEAD^{tree}' # lists animals.txt (blob) and birds (tree)
git cat-file -p 'HEAD:birds' # lists owl.txt (blob)The root tree has a blob entry for animals.txt and a tree entry for birds; the birds tree has a blob entry for owl.txt. Both blob entries show the same ID, so there is exactly one blob object: blobs are addressed by content, and both files contain cat\n. The file names live in the trees.
For step 4:
echo "dog" >> animals.txt
git add animals.txt
echo "fox" >> animals.txt
git statusanimals.txt is listed under both "Changes to be committed" (the index has cat, dog) and "Changes not staged for commit" (the working tree has cat, dog, fox). A commit now would include dog but not fox.
Key takeaways
- Every commit is a full snapshot; diffs are computed on demand, and unchanged files are shared between snapshots.
- The object store holds four content-addressed types: blobs (file contents), trees (directories), commits and annotated tags.
- A commit ID is a hash of its tree, parents, metadata and message, so changing any part of history creates new IDs.
- A branch is just a file containing a commit ID;
HEADnormally points at the current branch. - Commands move data between the working tree, the index and the repository, and commits are built from the index, not from disk.
Check yourself
Quiz
0/4 correct · 4 left
1.Two different files in a repository contain exactly the same bytes. How many blob objects does Git store for them?
2.What does a branch such as main actually consist of?
3.You run git add app.js, then edit app.js again, then run git commit. What ends up in the commit?
4.Why does fixing a typo in an old commit's message give that commit, and all commits after it, new IDs?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- Git’s object modelBlobs, trees and commits, each named by the hash of its content — a snapshot store, not a diff store.
- Working tree, index and HEADThree copies of your files — and every “undo” command is about which of them it changes.
- Branches, refs and HEADA branch is a file holding one commit hash; HEAD says which branch you’re on.
Finished reading?
Mark it complete to fill in your roadmap.