Git Internals — Objects, Refs, the Real Storage
Underneath the porcelain commands is a content-addressed key-value store. Knowing the model demystifies “what does git actually do” questions and makes recovery commands obvious.
Four object types
Everything in git is one of four objects, each identified by the SHA-1 (now SHA-256 in newer repos) of its content:
| Object | Stores | Analogous to |
|---|---|---|
| blob | file contents (no name, no metadata) | a file’s bytes |
| tree | a list of (mode, type, hash, name) entries |
a directory listing |
| commit | a tree hash + parent hash(es) + author + committer + message | a snapshot |
| tag | a hash + tagger + message + signature (annotated tags only) | a labeled pointer |
That’s the whole storage model.
How a commit stores a snapshot
commit a1b2c3...
├── tree e4f5g6... # the root directory at this commit
│ ├── blob h7i8j9... README.md # file content
│ ├── tree k0l1m2... src/ # subdirectory
│ │ └── blob n3o4p5... main.py
│ └── ...
├── parent <previous commit hash>
├── author Alice <alice@example.com> 1700000000 +0000
├── committer Alice ...
└── message "Add main.py"
Each commit is a complete snapshot of the entire repo at that point — it points to a tree, which points to blobs and subtrees. Git is not storing diffs.
Diffs are computed on demand by comparing trees of two commits.
Content-addressed storage
A blob’s name is the SHA of its content. Two files with identical content have the same SHA — git stores them once.
echo -n "hello world" | git hash-object --stdin
# 95d09f2b10159347eece71399a7e2e907ea3df4f
echo "hello world" > a.txt
echo "hello world" > b.txt
git add a.txt b.txt
# both reference the same blob 95d09f2b...
Same content + same metadata → same SHA → already-stored. Branches that share file content share the underlying blob storage. This is why git repos are smaller than you’d expect.
Where objects live
.git/objects/
├── 95/d09f2b10159347eece71399a7e2e907ea3df4f # loose object, gzipped
├── pack/
│ ├── pack-*.pack # many objects compressed together with delta encoding
│ └── pack-*.idx # index into the pack
└── info/
Objects start as loose (one file per object). Eventually git gc consolidates them into packfiles with delta compression — finds similar objects and stores deltas.
Inspect:
git cat-file -t a1b2c3d # type
git cat-file -p a1b2c3d # pretty-printed contents
git cat-file -s a1b2c3d # size
For a commit, you’ll see:
tree e4f5g6...
parent f8a9b0...
author Alice <alice@example.com> 1700000000 +0000
committer Alice <alice@example.com> 1700000000 +0000
Add main.py
For a tree:
100644 blob h7i8j9... README.md
040000 tree k0l1m2... src
Refs — pointers to commits
A “branch” or “tag” is a tiny file containing a commit SHA.
.git/refs/
├── heads/
│ ├── main ← contains "a1b2c3d4..."
│ └── feature ← contains "e5f6g7h8..."
├── tags/
│ └── v1.0.0 ← contains "i9j0k1l2..."
└── remotes/
└── origin/
├── main
└── HEAD
HEAD is .git/HEAD. Usually a symbolic ref:
ref: refs/heads/feature
When you git switch feature, HEAD is updated to point to refs/heads/feature. When you commit, refs/heads/feature advances to the new commit SHA.
So:
- A branch is a file with a SHA.
- HEAD is a file pointing to a branch (usually).
- “Detached HEAD” = HEAD points directly at a commit SHA, not a branch.
Creating a branch is literally writing a file:
echo "a1b2c3d4..." > .git/refs/heads/new-branch
# equivalent to:
git branch new-branch a1b2c3d4
This is why branching is “free” in git.
Packed refs
For repos with many refs, individual files are slow. git gc packs them into .git/packed-refs:
# pack-refs with: peeled fully-peeled sorted
a1b2c3d4... refs/heads/main
e5f6g7h8... refs/heads/feature
i9j0k1l2... refs/tags/v1.0.0
Loose refs (newer changes) override packed refs.
The index (staging area)
.git/index
Binary file listing the next-commit’s tree: each path, its blob SHA, mode, mtime. git add updates this. git commit snapshots it as a tree object and creates a commit pointing to that tree.
Inspect:
git ls-files --stage
# 100644 95d09f2b... 0 README.md
# 100644 e5f6g7h8... 0 src/main.py
git gc (garbage collection)
Periodically (or on git gc) git:
- Packs loose objects into packfiles with delta compression.
- Prunes unreachable objects older than
gc.pruneExpire(default 2 weeks). - Compacts reflog.
Auto-runs when there are too many loose objects. Tunable, sometimes problematic on huge repos.
Why this matters in interviews
Understanding the model explains:
- Branches are cheap — just a 41-byte file. No copying.
- Renaming a file produces a “rename” in
git log— git detects identical blobs across commits. git fscklooks for objects unreferenced by any ref — that’s how it finds dangling commits (lost stashes, dropped branches).git pushis incremental — it sends only objects the remote doesn’t have.- History rewrite changes SHAs — because content (parent SHA) changed, so SHA changes.
- Identical content across history is stored once — even huge files committed and reverted don’t bloat the repo (much), as long as git can detect them as identical.
Plumbing vs porcelain commands
- Porcelain:
add,commit,branch,merge,log— what users use. - Plumbing:
hash-object,cat-file,update-ref,ls-tree,rev-parse,for-each-ref— primitives porcelain is built on.
Useful plumbing:
git rev-parse HEAD # show the commit SHA HEAD points to
git rev-parse --short HEAD # 7-char abbreviated form
git rev-parse origin/main # any ref → SHA
git rev-list --count main # how many commits in main
git symbolic-ref HEAD # what does HEAD point to (e.g. refs/heads/main)
git for-each-ref refs/heads # list all branches programmatically
Scripts that automate git workflows live on these.
SHA-1 vs SHA-256
Git defaults to SHA-1 still. SHA-256 repos exist (git init --object-format=sha256) but interop with old tools/hosts is limited. SHA-1 collisions in the wild (SHAttered, 2017) demonstrated the algorithm is broken; git mitigates with collision detection but not full SHA-256 by default. Long-running concern; not yet urgent for most repos.
Common interview confusions
- “Git stores diffs.” — no, it stores full snapshots. Diffs are computed when you ask for them. Packfiles use delta compression internally, but that’s an optimization invisible to users.
- “A branch contains commits.” — a branch is a pointer. Commits exist in the object database; the branch just names a tip.
- “Rebase loses commits.” — the new commits are added; the old ones become unreachable but stay in object storage until GC.
- “
git pulldownloads everything.” — only objects the remote has that you don’t. Incremental.
Interview angle
- “What’s a commit, really?” — an object containing a tree hash, parent hash(es), author, committer, message. The tree points to blobs and subtrees representing the snapshot.
- “Does git store diffs or snapshots?” — snapshots. Each commit has a tree; identical files share blobs (content-addressed). Packfiles compress deltas internally for efficiency, but the model is snapshots.
- “What’s a branch in git?” — a file containing a commit SHA. Creating a branch is writing 41 bytes; that’s why branching is free.
- “What happens when you
git commit?” — git snapshots the staging area into a tree, creates a commit object with parents/author/message, advances the current branch ref to point at the new commit, updates HEAD. - “What’s HEAD?” — usually a symbolic ref pointing at a branch (
ref: refs/heads/main). Detached HEAD means it points directly at a commit instead. - “How does
git fsckfind dangling commits?” — walks all refs (and reflog) and marks reachable objects; unreachable objects are dangling. Used for recovering dropped stashes / orphaned commits before GC removes them. - “Why are git operations on huge repos slow?” — lots of objects, lots of refs, large packfiles to scan.
git gc,git repack, partial clone, sparse checkout, and shallow clone are the levers.