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.

16 min 7 sections

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:

bash
mkdir recipes && cd recipes
git init
echo "# Recipes" > README.md
git add README.md
git commit -m "Add README"
ls .git
text
COMMIT_EDITMSG  HEAD  branches  config  description  hooks  index  info  logs  objects  refs

The 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:

bash
echo "# Recipes" | git hash-object --stdin
text
ae5e354863163528256fab6c692aaada622e83bd

Run 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:

TypeStoresAnalogy
blobThe contents of one file (no name, no permissions)A page of text
treeA directory listing: names, modes and the IDs of blobs and sub-treesA folder
commitA pointer to one root tree, parent commit(s), author, committer, messageA labelled photograph of the folder
tagAn annotated tag: a named, signed-or-dated pointer to another objectA 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:

bash
git cat-file -p HEAD
text
tree a5f17f18f58a4e1ecdb3dd67cf79256d0e47ec79
author Ada Lovelace <[email protected]> 1791705809 +0000
committer Ada Lovelace <[email protected]> 1791705809 +0000

Add README

Follow the tree:

bash
git cat-file -p a5f17f1
text
100644 blob ae5e354863163528256fab6c692aaada622e83bd	README.md

And the blob is just your file's bytes:

bash
git cat-file -p ae5e354
text
# Recipes

Notice 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:

bash
printf 'flour\nwater\n' > bread.txt
git add bread.txt
git commit -m "Add bread recipe"
git cat-file -p HEAD
text
tree 033a14155e766d61adc46462043bbbdd19058fc2
parent a4a62c4c35659576c52a5b594304b60ebfbea51b
author Ada Lovelace <[email protected]> 1791705809 +0000
committer Ada Lovelace <[email protected]> 1791705809 +0000

Add bread recipe

The 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:

text
commit 1d62648 ──parent──▶ commit a4a62c4
      │                          │
      ▼                          ▼
 tree 033a141                tree a5f17f1
  ├─ README.md ─────┐         └─ README.md ─┐
  └─ bread.txt ─┐   │                       │
                │   └──────▶ blob ae5e354 ◀─┘
                ▼
          blob 400b4ab

Because 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/:

bash
cat .git/refs/heads/main
text
1d6264841a6f3c961571839444f2df534592393e

That 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:

bash
cat .git/HEAD
text
ref: refs/heads/main

So 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.

text
                 HEAD
                  │
                  ▼
                 main
                  │
                  ▼
   a4a62c4 ◀── 1d62648

In 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.

  1. Working tree – the ordinary files and folders you edit in your editor.
  2. 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.
  3. Repository – the object store and refs in .git: every commit you have ever made.
text
  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:

bash
git ls-files --stage
text
100644 ae5e354863163528256fab6c692aaada622e83bd 0	README.md
100644 400b4ab3d84db71d4249998c7f8fc39899b368fe 0	bread.txt

Watching a file in all three areas

Here is the experiment that makes the model click. Edit a file, stage it, then edit it again:

bash
echo "salt" >> bread.txt
git add bread.txt
echo "yeast" >> bread.txt
git status
text
On 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.txt

The same file appears twice, and that is correct. There are now three versions of bread.txt:

AreaContents of bread.txt
Repository (HEAD)flour, water
Indexflour, water, salt
Working treeflour, 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:

  1. Create a repository called zoo, add a file animals.txt containing cat, and commit it.
  2. Create a sub-directory birds/ with birds/owl.txt containing cat (yes, the same text), stage it and commit.
  3. Use git cat-file -p to walk from HEAD down to every blob. How many blob objects exist for the two files, and why?
  4. Append dog to animals.txt, stage it, then append fox. Predict what git status will report, then check.
Show solution
bash
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:

bash
echo "dog" >> animals.txt
git add animals.txt
echo "fox" >> animals.txt
git status

animals.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; HEAD normally 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. 1.Two different files in a repository contain exactly the same bytes. How many blob objects does Git store for them?

  2. 2.What does a branch such as main actually consist of?

  3. 3.You run git add app.js, then edit app.js again, then run git commit. What ends up in the commit?

  4. 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.

Finished reading?

Mark it complete to fill in your roadmap.