DevLift
Back to Blog

How Git Works Under the Hood

You've run git commit thousands of times. But have you ever wondered what actually happens when you do? Git isn't a file-syncing tool with version numbers bolted on.

Admin
June 15, 20269 min read0 views

How Git Works Under the Hood

You've run git commit thousands of times. But have you ever wondered what actually happens when you do? Git isn't a file-syncing tool with version numbers bolted on. It's a content-addressable object database with a thin command-line interface layered on top. Once you see that, everything — rebases, detached HEAD, force-push disasters, reflog magic — clicks into place.

Let's look at what Git is actually doing every time you type a command.

The core idea: content-addressable storage

Git doesn't track files by name or path. It tracks content. Every piece of data you put into Git gets SHA-1 hashed, and that hash becomes the object's identity and address.

SHA-256 does exist, but do not let anyone tell you the migration has happened. extensions.objectFormat accepts sha256, and Git's own docs say of the SHA-1/SHA-256 interop extension: "Note that the functionality enabled by this extension is incomplete and subject to change. It currently exists only to allow development and testing of the underlying feature and is not designed to be enabled by end users." Every repo you will touch this year is SHA-1. The object model is identical either way, which is why it barely matters for understanding it.

If two files have identical content, they share the same hash and Git stores them exactly once. Rename a file without changing it? Git doesn't store a new blob — it just updates the tree pointing to the same blob. This is why Git is fast and storage-efficient even in large monorepos.

The entire object store lives in .git/objects/. Every object is a zlib-compressed file, named by its SHA-1 hash — first two characters become the directory, the remaining 38 become the filename.

Everything from here on comes from one throwaway repo: four files (.gitignore empty, README.md containing hello world, src/index.ts, src/utils.ts), then a second commit adding src/auth.ts. Two commits, and this is what is on disk:

$ find .git/objects -type f | sort
.git/objects/05/1155ba9ca11ad39ef3025147d5c5eedf3029c5
.git/objects/12/641a7b2a8642c5a5d65dc3350fd4028706ce3c
.git/objects/2c/a9c9a0d5710890f4467840e14fff9c5d548b41
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/5e/7cba124311db90748bebfd942e0ca7b485e30f
.git/objects/61/717d2595197a2b33c5a7caf995fe4242f40de2
.git/objects/99/e9e5cda81e311ddb4705d709d164bc601b6a72
.git/objects/a6/7bbe858ea286dc746b6af36a7f3a880e2e6921
.git/objects/db/9c67ecf8d983cb364518e01b30ec1c0452fedb
.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
.git/objects/fd/52b9c66a69450252e190481f05af8b87c98e91

Two things to notice. First, count the characters after the slash: 38, not 40 — the directory holds the other two. Second, eleven objects for two commits on five files. Five blobs, two commits, and four trees — because the second commit needed a new src tree and a new root tree, and Git kept the old ones. That multiplication is the price of snapshots, and it's what pack files exist to claw back.

You can inspect any of these with the plumbing command git cat-file:

$ git cat-file -t 3b18e512
blob
 
$ git cat-file -p 3b18e512
hello world

The four object types

Git has exactly four object types. Everything else is built from these.

Rendering diagram...

Blob

A blob is just bytes. File contents, no metadata, no filename. Two files with identical content → one blob. This is the leaf node of every tree.

$ echo "hello world" | git hash-object --stdin
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

You do not have to take my word for that one. It is sha1("blob 12\0hello world\n"), it has been the same value since 2005, and it is the same value on your machine right now. That determinism is the whole design.

Tree

A tree represents a directory. It holds entries of the form (mode, name, hash) pointing to blobs or other trees. Mode encodes whether the entry is a regular file (100644), executable (100755), or a symlink (120000).

$ git cat-file -p HEAD^{tree}
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	.gitignore
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	README.md
040000 tree 12641a7b2a8642c5a5d65dc3350fd4028706ce3c	src

Two of those hashes you can recognise on sight after a while. e69de29b... is the empty blob — every empty file in every Git repo on earth. 3b18e512... is hello world\n, the same value we hashed above, because README.md contains exactly that.

Trees are immutable. Change one file, and Git creates a new blob for that file, a new tree for its directory, and new trees all the way up to the root. The parent commit gets a new root tree hash. This is why every commit is a complete snapshot, not a diff.

Commit

A commit object contains:

  • A pointer to the root tree (the snapshot)
  • Zero or more parent commit hashes
  • Author name/email + timestamp
  • Committer name/email + timestamp
  • The commit message
$ git cat-file -p HEAD
tree a67bbe858ea286dc746b6af36a7f3a880e2e6921
parent 051155ba9ca11ad39ef3025147d5c5eedf3029c5
author Harsh Jain <harsh@example.com> 1785749000 +0530
committer Harsh Jain <harsh@example.com> 1785749000 +0530
 
feat: add user authentication

Cross-check that against the listing above: a67bbe8 is the root tree, 051155b is the initial commit, and the commit object itself is 2ca9c9a. Every hash in this article comes from that one repo, so if two of them didn't line up, you would be able to tell.

Note the timestamp format too: seconds since the epoch, then the author's UTC offset as a separate field. Git stores no timezone name, only the offset the committer's machine had at the time.

That parent pointer is what creates the commit graph.

Annotated Tag

A tag object wraps another object (usually a commit) with a name, a message, and a signature. Lightweight tags are just refs (more on those below). Annotated tags — created with git tag -a — are first-class objects with their own SHA-1 in the object store.

The commit DAG

Commits form a directed acyclic graph. Each commit points to its parent(s), never backward. Merge commits have two parents. Octopus merges have more. No commit ever points to a future commit, which is what makes it acyclic.

The labels below are descriptions rather than hashes, to keep them apart from the real ones elsewhere in this article:

Rendering diagram...

This graph structure is why git log can walk history, why git bisect can binary-search it, and why rebase works the way it does (it replays commits onto a new base, creating new SHA-1s — the original commits still exist until garbage collected).

Refs: named pointers into the DAG

Raw SHA-1 hashes are unworkable for humans. Refs are named pointers to specific commits (or other objects). They live in .git/refs/:

$ find .git/refs -type f | sort
.git/refs/heads/feature/auth
.git/refs/heads/main
 
$ cat .git/refs/heads/main
b634d8997747b429a5dca269d5e84071b2556b12

A branch is a file containing a commit hash and a newline. That's the whole implementation. Note that feature/auth is a real directory called feature with a file called auth in it, which is why you cannot have both a branch feature and a branch feature/auth — one would have to be a file and a directory at once. Git's error for that (cannot lock ref) makes a lot more sense once you know it's a filesystem constraint leaking through.

When you commit, Git rewrites that file. Branches aren't containers — they're bookmarks. (git gc will move refs into a single .git/packed-refs file, so don't be surprised when .git/refs/heads/ looks empty on an older repo.)

HEAD is a special ref in .git/HEAD that points to the current branch (or directly to a commit in "detached HEAD" mode):

# Normal state — HEAD points to a branch
$ cat .git/HEAD
ref: refs/heads/main
 
# After `git checkout 2ca9c9a` — HEAD holds the commit directly
$ cat .git/HEAD
2ca9c9a0d5710890f4467840e14fff9c5d548b41
⚠️

Detached HEAD isn't dangerous on its own — you're just pointing directly at a commit. It only becomes a problem if you make new commits and then switch branches without creating a new ref to hold them. The commits exist but become unreachable from any ref, eventually garbage-collected.

The index (staging area)

The staging area — git add territory — is a binary file at .git/index. It's a sorted list of file paths with their current blob hashes and mode bits, representing the state of the next commit.

The three-tree model:

Working Directory  ←→  Index (staging)  ←→  HEAD (last commit)
 
git add file.ts copies file state into index
git commit creates tree/commit from index, updates HEAD
git checkout HEAD file copies HEAD state into index AND working directory
git reset HEAD file copies HEAD state into index only

Understanding this model explains why git reset and git checkout sometimes behave unexpectedly — you're moving data between different trees.

Pack files: the storage optimization

Fresh repositories store each object as a separate compressed file. As history grows, this becomes inefficient — especially for large blobs with minor changes between versions. Git's answer is pack files.

A pack file bundles multiple objects into a single binary archive, using delta compression: instead of storing the full content of each version of a file, it stores a base object and deltas (differences) from that base.

$ ls .git/objects/pack/
pack-e2b9797d6ec0d1b0e4ba1f026a906080e9bdfdd5.idx     ← index for O(log n) lookup
pack-e2b9797d6ec0d1b0e4ba1f026a906080e9bdfdd5.pack    ← the compressed binary data

The name is not a hash of the pack's contents in the way an object name is — it's a hash over the sorted object names inside it, so the same set of objects always produces the same pack name.

The .idx file has two sections: a fan-out table for quick range narrowing, then a sorted list of SHA-1s with offsets into the .pack file. Finding any object in a pack is ~O(log n) via binary search.

Packs get created:

  • Explicitly, by git gc or git repack
  • On git fetch or git clone, because the pack the remote sent gets stored as-is
  • Automatically, when gc.auto (default 6700 loose objects) trips during a command that creates objects

git push also builds a pack, but a transient one for the wire — it does not repack your local object store.

# Repack and prune unreachable objects
git gc
 
# Repack into a single pack and delete the packs it replaced
git repack -ad
 
# See what's in a pack file
git verify-pack -v .git/objects/pack/pack-*.idx | head -20
⚠️

git pack-objects --all .git/objects/pack/pack < /dev/null gets recommended as the "just pack, don't prune" option. It isn't. Run it on the repo above, which by then had 19 loose objects: it exits 0, writes a brand-new .pack/.idx pair containing everything reachable — and leaves all 19 loose objects exactly where they were. Your objects are now stored twice. git repack -d is the command that actually replaces loose objects with a pack.

💡

Delta compression in packfiles is surprisingly smart: Git picks bases across different files, not just successive versions of the same file. A new version of a large JavaScript file might be stored as a delta of another large file with similar content.

The reflog: your safety net

Here's something Git quietly does: it logs every time a ref changes, in .git/logs/. This is the reflog.

Continuing in the same repo — branch off, commit, come back, commit again:

$ git reflog
b634d89 HEAD@{0}: commit: feat: finish auth
2ca9c9a HEAD@{1}: checkout: moving from feature/auth to main
91d4324 HEAD@{2}: commit: wip: halfway through auth
2ca9c9a HEAD@{3}: checkout: moving from main to feature/auth
2ca9c9a HEAD@{4}: commit: feat: add user authentication
051155b HEAD@{5}: commit (initial): chore: initial commit

2ca9c9a appears three times, which is the reflog telling you exactly what it is: a log of where HEAD pointed, not a list of commits. Three entries, one commit — created, left, returned to.

No (HEAD -> main) decoration in there, incidentally, even on a terminal — git reflog runs through cmd_log_reflog, which never enables ref decoration regardless of log.decorate. If you want it, ask for it: git log -g --decorate --oneline.

The reflog is why you can recover from almost any disaster. Accidentally deleted a branch? The commits are still in the object store — you just need to find their SHA-1 and create a new ref pointing to them. The reflog shows where HEAD has been.

# Find the commit from the deleted branch — note the SHA in column 1
git reflog --all | grep "feature/payments"
 
# Re-create the branch at that exact SHA
git branch feature/payments <sha-from-above>

Use the SHA, not a HEAD@{n} selector. The selector is positional and shifts every time you touch a ref, so the entry you were aiming at moves out from under you.

Reflog entries expire (default: 90 days for reachable, 30 days for unreachable), after which git gc will actually delete the orphaned objects. Until then, you're safe.

Practical implications

Why rebase rewrites history. Rebase takes commits and replays them on a new base. Since a commit's hash depends on its content and its parent hash, replaying on a different base produces new SHA-1s. The old commits still exist (check git reflog) until GC'd. This is why you should never rebase commits that others have pulled.

Why force-push is destructive. Force-pushing overwrites the remote ref to point to your local commits. Anyone who pulled the old commits now has commits that are "orphaned" in the remote's history. Their next pull will show diverged history. For shared branches, this is a bad time.

Why git stash is a commit. git stash creates two (or three) commit objects in the object store and points a special ref at them. Running git stash list reads from those refs. There's nothing magical — it's the same object model, just with a different ref namespace.

Why shallow clones can be weird. A shallow clone (git clone --depth=1) writes a .git/shallow file listing commits whose parents are "grafted" — artificially treated as root commits. This is a lie Git tells itself for performance. Pushing from a shallow clone can cause problems if the remote doesn't support it.

Content identity as a bug detector. Because Git hashes the full content of every object, any corruption in a stored object changes its hash, making it unreadable. git fsck walks the entire object graph, verifying every hash. It's also useful for finding dangling (unreachable) commits and blobs that are taking up space.

# Check repository integrity
git fsck --strict
 
# Find all dangling objects (potential recovery targets)
git fsck --unreachable | grep commit

The next time a coworker asks why git reset --hard "lost" their commits, you can explain that the commits are still in the object store, the branch ref just no longer points to them — and git reflog will find them. That's the kind of precision that separates someone who uses Git from someone who understands it.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read