πŸ“š Learning Hub
Β· 7 min read

How Git Actually Works β€” Objects, Trees, Commits, and Refs


Most developers use Git every day without thinking about what’s happening inside the .git directory. We type git add, git commit, git push, and move on. But Git isn’t magic β€” it’s a content-addressable filesystem with a version control system built on top. Once you understand the internals, every command clicks into place, and debugging weird states stops being scary.

Let’s crack it open.

The .git directory

When you run git init, Git creates a .git folder with this structure:

.git/
β”œβ”€β”€ HEAD            # Points to current branch
β”œβ”€β”€ index           # The staging area
β”œβ”€β”€ objects/        # All content (blobs, trees, commits, tags)
β”œβ”€β”€ refs/           # Branch and tag pointers
β”‚   β”œβ”€β”€ heads/      # Local branches
β”‚   └── tags/       # Tag references
β”œβ”€β”€ config          # Repo-level configuration
└── hooks/          # Client/server-side scripts

Everything Git knows lives here. Delete this folder and your repo is gone. The working directory is just a checkout of whatever HEAD points to.

Git’s object model

Git stores everything as objects in .git/objects/. Every object is identified by a SHA-1 hash of its content. This is what β€œcontent-addressable” means β€” the address (hash) is derived from the content itself. Identical content always produces the same hash.

There are four object types:

Blobs

A blob stores file content β€” just the raw bytes, no filename, no permissions. If two files have identical content, they share the same blob.

blob 13\0Hello, world!
         ↓ SHA-1
    β†’ a5c19667710c39d4cdb98e5f4ae7d3a4b0b0e5a2

Git prepends a header (blob <size>\0) before hashing. The result is a 40-character hex string. The first two characters become a directory name, the rest becomes the filename inside .git/objects/.

Trees

A tree maps filenames and directories to blobs and other trees. Think of it as a directory listing snapshot.

tree 3a4b...
β”œβ”€β”€ 100644 blob a5c1...  README.md
β”œβ”€β”€ 100644 blob 7f2e...  index.js
└── 040000 tree b8d3...  src/
                          β”œβ”€β”€ 100644 blob 9c1a...  app.js
                          └── 100644 blob 4e7f...  utils.js

Each entry has a mode (file permissions), object type, SHA-1 hash, and filename. Trees are recursive β€” a tree can point to other trees, which is how Git represents nested directories.

Commits

A commit object ties everything together. It points to a tree (the project snapshot), one or more parent commits, and stores metadata:

commit 2f8a...
β”œβ”€β”€ tree     3a4b...       # Root tree of this snapshot
β”œβ”€β”€ parent   e7c1...       # Previous commit (none for first commit)
β”œβ”€β”€ author   Jane <jane@example.com> 1720300800 +0000
β”œβ”€β”€ committer Jane <jane@example.com> 1720300800 +0000
└── message  "Add user authentication"

A merge commit has multiple parents. The first commit in a repo has no parent.

Tags

Annotated tags are objects too. They point to a commit and store a tagger, date, and message. Lightweight tags are just refs (more on that below) β€” they don’t create an object.

SHA-1 hashing and integrity

Every object’s hash is computed from its content. This gives Git two properties:

  1. Integrity β€” if a single byte changes, the hash changes. Git detects corruption automatically.
  2. Deduplication β€” identical content is stored once, regardless of how many files or commits reference it.

When you see a commit hash like e7c1d3a..., that’s the SHA-1 of the commit object’s content (which includes the tree hash, which includes blob hashes). Change anything in history and every subsequent hash changes. This is why rewriting history changes commit hashes.

Commits form a DAG

Commits point to their parents, forming a Directed Acyclic Graph (DAG). It’s directed because links go child β†’ parent. It’s acyclic because you can’t create a loop.

A ← B ← C ← D        (main)
         ↑
         └── E ← F    (feature)

Here, D and F are the tips of two branches. Both C’s children point back to it. A merge creates a commit with two parents:

A ← B ← C ← D ← G    (main, after merge)
         ↑         ↑
         └── E ← Fβ”˜   (feature merged in)

Commit G has two parents: D and F. The entire history is this graph β€” branches are just entry points into it.

What branches really are

A branch is a pointer. That’s it. It’s a file in .git/refs/heads/ containing a single commit hash.

$ cat .git/refs/heads/main
e7c1d3a4b0b0e5a2f8a19667710c39d4cdb98e5f

$ cat .git/refs/heads/feature
4e7f9c1a7f2eb8d33a4b0b0e5a2f8a19667710c3

When you make a new commit on a branch, Git updates that file to point to the new commit. Creating a branch is just writing a 40-byte file. That’s why branching in Git is nearly instant β€” there’s no copying.

HEAD β€” where you are right now

HEAD is a symbolic reference that usually points to a branch:

$ cat .git/HEAD
ref: refs/heads/main

This means β€œI’m on the main branch.” When you commit, Git updates the branch that HEAD points to.

If you checkout a specific commit (not a branch), HEAD points directly to a commit hash β€” this is β€œdetached HEAD” state. Commits made here aren’t on any branch, which is why Git warns you. If you’ve ever ended up in this state unexpectedly, the fix is similar to other common Git errors.

The staging area (index)

The index is a binary file at .git/index. It sits between your working directory and the object database:

Working Directory  β†’  Index (Staging Area)  β†’  Object Database
   (your files)       (.git/index)              (.git/objects/)

The index holds a sorted list of file paths, each with the SHA-1 of the blob it maps to, file mode, and timestamps. It’s essentially a proposed next commit.

What β€˜git add’ actually does

When you run git add README.md:

  1. Git computes the SHA-1 of the file’s content
  2. Compresses the content and stores it as a blob in .git/objects/
  3. Updates the index entry for README.md to point to the new blob hash

The blob is written immediately. Your content is safely in the object database before you ever commit. This is also why git stash works β€” it creates commits from the index and working tree state.

What β€˜git commit’ actually does

When you run git commit -m "Fix bug":

  1. Git reads the index and creates tree objects for every directory
  2. Creates a commit object pointing to the root tree, the current HEAD as parent, and your message
  3. Updates the current branch ref to point to the new commit
  4. Updates HEAD (if it’s symbolic, the branch it points to gets updated)

That’s the whole operation. No diffs are stored β€” Git stores full snapshots. Diffs are computed on the fly when you ask for them.

How Git computes diffs

Since Git stores complete trees per commit, it compares two trees to produce a diff. It walks both trees, compares blob hashes, and only looks at the actual content when hashes differ. Same hash = same content = skip. This makes diffing large repos fast.

Packfiles β€” keeping things small

Storing full copies of every file version would waste space. Git periodically runs garbage collection (git gc) which packs loose objects into packfiles in .git/objects/pack/. Inside a packfile, Git uses delta compression β€” it stores one full copy of an object and then deltas (differences) for similar objects. This is a storage optimization only; the logical model is still full snapshots.

You can trigger this manually:

git gc
git verify-pack -v .git/objects/pack/*.idx

The reflog β€” your safety net

The reflog records every time a ref (branch, HEAD) changes. It’s stored in .git/logs/.

$ git reflog
e7c1d3a HEAD@{0}: commit: Fix bug
a5c1966 HEAD@{1}: checkout: moving from feature to main
4e7f9c1 HEAD@{2}: commit: Add feature

Even if you reset, rebase, or delete a branch, the reflog remembers where refs pointed. You can recover β€œlost” commits:

git checkout HEAD@{2}
# or
git branch recovered-branch HEAD@{2}

The reflog expires after 90 days by default (30 days for unreachable commits). Until then, almost nothing is truly lost. This is invaluable when a push gets rejected after a rebase and you need to find the pre-rebase state.

Putting it all together

Here’s the full picture of what happens when you edit a file and commit:

1. Edit src/app.js
2. git add src/app.js
   β†’ New blob written to .git/objects/
   β†’ Index updated with new blob hash

3. git commit -m "Update app"
   β†’ Tree objects created from index
   β†’ Commit object created (points to root tree + parent)
   β†’ refs/heads/main updated to new commit hash
   β†’ Reflog entry written

Every piece of content is hashed, every commit points to a complete snapshot, branches are just movable pointers, and the reflog tracks everything. That’s Git.

Why this matters

Understanding the object model helps you:

  • Debug confidently β€” detached HEAD, dangling commits, and merge conflicts make sense when you see the graph
  • Use the reflog β€” you know commits aren’t deleted, just unreferenced
  • Write better .gitignore files β€” you understand that Git tracks content, not files
  • Reason about history rewriting β€” rebase, amend, and filter-branch all create new objects with new hashes

Git is a simple data structure β€” a DAG of content-addressed objects with named pointers. Everything else is tooling built on top of that foundation.

πŸ“˜