At its core, Git isn’t about “versions of files” — it’s about snapshots of content.
Most version control systems (like SVN) store differences — the delta between file versions.
Git stores snapshots — each commit is a complete snapshot of your project’s state (with deduplication for unchanged files).
Whenever you run git init, Git creates a hidden .git folder — this is the entire repository. Your working directory is just a convenience layer.
Inside .git, you’ll find key components:
├── config
├── description
├── HEAD
├── index
├── info
│ ├── exclude
│ └── refs
├── logs
│ ├── HEAD
│ └── refs
├── objects
│ ├── info
│ └── pack
├── packed-refs
└── refs
├── heads
└── tagsLet’s decode these one by one.
Everything in Git is stored as an object inside .git/objects/.
There are four types of objects:
Git stores all these as content-addressable objects, meaning:
The object’s filename = SHA-1 hash of its contents.
When you git add file.txt, Git compresses and stores the file contents as a blob object.
You can inspect it with:
git hash-object file.txtThis command outputs something like:
9562e8b2802fe9b2ea5764741c19f37847cb8acfThis is a SHA-1 hash. Inside .git/objects, Git stores it as:
.git/objects/95/62e8b2802fe9b2ea5764741c19f37847cb8acfSo Git doesn’t store filenames — just file contents. The link between names and blobs comes later (in trees).
A tree object represents the structure of a directory. Each entry in a tree object maps a filename to:
You can see tree content with:
git cat-file -p <tree-hash>100644 blob b2d8968efa87a1845f3a94ca73618a194c2304a1 README.md
040000 tree dddd84a1983a6438c2f4831b012c214bb17df43e srcSo a tree is basically the directory listing, recursively pointing to blobs and subtrees.
A commit object points to:
You can view a commit object like this:
git cat-file -p <commit-hash>tree 421da6484e3079368699a902463f9ac09898a71b
parent 33f9d5b4d4998b2e12b77bac1f6dad96d901030b
author admincodes7 <arjunbanur27@gmail.com> 1761184353 +0530
committer admincodes7 <arjunbanur27@gmail.com> 1761184353 +0530
Just to see parent of this commitWhen you git add, Git doesn’t commit yet — it updates the index (a binary file stored at .git/index). The index maps:
<path> → <blob-hash>So when you git commit, Git:
That’s it — no magic.
A branch in Git is simply a file that stores a commit hash.
.git/refs/heads/main → a1b2c3d4...HEAD is another file:
.git/HEAD → ref: refs/heads/mainSo when you git commit, Git:
A tag is a named reference to a commit. Lightweight tags just point to a commit hash. Annotated tags are full objects containing metadata.
object 33d906ce59d2e29f647bb182cda61eaa22f12c72
type commit
tag v1.0
tagger admincodes7 <arjunbanur27@gmail.com> 1761185097 +0530
Release version 1.0Let’s trace one complete flow:
git add
git commit
git checkout
A merge commit has two parents:
parent 1a410ef...
parent b7d34e2...Git finds the common ancestor (the merge base), performs a three-way merge, and creates a new commit that has both as parents.
If conflicts arise, it just writes conflict markers in the working directory — Git never auto-edits blobs beyond that.
When you git stash, Git actually creates two commits:
Then it stores a reference to those commits under:
refs/stashYou can view them using:
git cat-file -p refs/stashSo stash isn’t some temporary buffer — it’s a hidden commit chain.
When you delete a branch or rewrite history, some commits become unreachable.
Git doesn’t delete them immediately — it keeps them as loose objects.
Running git gc (garbage collection) packs these objects into .git/objects/pack/ files and removes unreachable ones after a grace period.
So even after “deleting,” your data lingers until cleanup.
Individual objects (in .git/objects/) are inefficient for large repos.
Git’s packfiles combine objects and store them delta-compressed.
That’s what happens during:
git gc.git/objects/pack/
├── pack-xxx.pack
├── pack-xxx.idxThese pack files are highly optimized — Git can store thousands of versions of a file efficiently.
When you git push or git fetch, Git transfers objects by comparing hashes.
A refspec defines what refs to push/fetch:
refs/heads/main:refs/remotes/origin/mainSo git push origin main means:
Send my local refs/heads/main commit chain to origin’s refs/heads/main.
Everything is hash-driven. If the remote already has an object (by hash), Git skips it — no redundancy.
Everything is hash-driven. If the remote already has an object (by hash), Git skips it — no redundancy.
To summarize:
Commit → Tree → (Subtrees + Blobs)
↑
Branches & Tags (refs)Everything is content-addressable by hash. That’s why Git is immutable and trustworthy — you can’t fake history without changing hashes.
Git is a key–value store, where:
key = SHA-1(content)
value = object (blob/tree/commit/tag)The rest — branches, merges, remotes — are just clever abstractions built on top of that.
Next time you type git commit, remember:
You’re not “saving changes”. You’re minting a new immutable object into a beautifully designed content-addressable universe.