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:
- Integrity β if a single byte changes, the hash changes. Git detects corruption automatically.
- 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:
- Git computes the SHA-1 of the fileβs content
- Compresses the content and stores it as a blob in
.git/objects/ - Updates the index entry for
README.mdto 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":
- Git reads the index and creates tree objects for every directory
- Creates a commit object pointing to the root tree, the current
HEADas parent, and your message - Updates the current branch ref to point to the new commit
- 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.