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

Git & Collaboration roadmap · Core concepts · 16 min read · https://levelcode.org/roadmaps/git/how-git-works

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.

> [!NOTE]
> Newer Git versions can create repositories that use SHA-256 instead of SHA-1 (`git init --object-format=sha256`). The ideas are identical; only the ID length changes. Hosting services still default to SHA-1, so that is what you will see day to day.

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

```bash
git cat-file -p HEAD
```

```text
tree a5f17f18f58a4e1ecdb3dd67cf79256d0e47ec79
author Ada Lovelace <ada@example.com> 1791705809 +0000
committer Ada Lovelace <ada@example.com> 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 <ada@example.com> 1791705809 +0000
committer Ada Lovelace <ada@example.com> 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.

> [!IMPORTANT]
> Commit IDs on your machine will differ from the ones shown here, because they include your name and the exact time you committed. Blob and tree IDs for identical content will match exactly.

## 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`:

| 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").

> [!TIP]
> When a command surprises you, ask "which of the three areas did that touch?". Almost every confusing moment in Git — a change that did not get committed, a file that "came back" — is answered by that question.

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

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

## Quiz

1. Two different files in a repository contain exactly the same bytes. How many blob objects does Git store for them?

   A. Two, one per file path
   B. None until the files differ
   C. One, because blobs are addressed by their content
   D. One per commit that includes them

   **Answer: C.** A blob's ID is the hash of its content, and names live in trees. Identical content means one blob, referenced from two tree entries. It is also reused across commits rather than copied.

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

   A. A small ref file that holds the ID of one commit
   B. A copy of the project's files
   C. A list of every commit on the branch
   D. A tree object listing the branch's files

   **Answer: A.** A branch is a ref, a tiny file under .git/refs/heads containing one commit ID. The rest of the branch's history is found by following parent pointers from that commit.

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

   A. Both versions, as two commits
   B. The latest version on disk
   C. Nothing, because the file changed after staging
   D. The version of app.js that was in the index when you ran git add

   **Answer: D.** Commits are built from the index. git add copied the file's content at that moment into the index; the later edit exists only in the working tree until you stage it.

4. Why does fixing a typo in an old commit's message give that commit, and all commits after it, new IDs?

   A. Git renumbers commits sequentially after any edit
   B. A commit ID is a hash that covers the message and the parent ID, so changes ripple through descendants
   C. Hosting services require new IDs for edited commits
   D. Only the edited commit changes; descendants keep their IDs

   **Answer: B.** The message is part of the hashed commit content, so its ID changes. Each child stores its parent's ID, so the child's content changes too, and so on down the chain.
