Most developers learn Git as a set of commands to memorize: git add, git commit, git push. But when things go wrong—a lost commit, a mysterious merge conflict, a branch that won't delete—those commands feel like magic incantations. Understanding how Git works under the hood for Git & GitHub developers isn't just academic; it's the difference between cargo-culting through version control and actually owning your workflow.
Git's elegance lies in its simplicity. At its core, it's a content-addressable filesystem wrapped around a handful of object types. Once you understand the mental model—snapshots instead of diffs, objects instead of files, pointers instead of magic—you'll debug faster, recover from mistakes with confidence, and make better decisions about branching strategy.
Snapshots, not diffs — Each commit points to a full snapshot of the project
A common misconception is that Git stores deltas between commits, like a series of patches. It doesn't. Each commit is a complete snapshot of your entire project at that moment in time. This design choice has profound implications for how Git works, how fast it can operate, and how safely it can recover history.
When you run git commit, Git doesn't calculate "what changed since the last commit" and store only those differences. Instead, it records the entire state of the project. If you have 10,000 files and only modify one, Git still creates a snapshot of all 10,000 files. This sounds wasteful, but Git is clever about storage—it uses content-addressed deduplication. Files that haven't changed are referenced by their hash; identical content across commits shares the same storage.
Let's walk through a real scenario. You're working on a feature branch. After three commits, your .gitlog looks like this:
$ git log --oneline
a3f9e2c Add validation to checkout form
b8c1d7f Update payment gateway integration
c2e5f1a Initialize project structureEach of these commit hashes represents a complete, independent snapshot of your repository. The commit a3f9e2c doesn't mean "the checkout form validation was added since the previous commit." It means "this is what the entire repository looked like after adding that validation." You could theoretically check out commit a3f9e2c and have a fully working (or broken) project—no other commits are needed to understand its state.
This is why Git can:
- Rebase commits safely—each snapshot is independent
- Recover lost commits—the content still exists in the object store
- Blame with confidence—the exact state at any point in history is preserved
The snapshot model also explains why git log is instant and why switching branches is fast (Git only needs to update the files that changed). With diffs, both operations would require replaying a series of changes sequentially.
Objects — Blobs store files, trees store folders, commits store metadata
Under the hood, Git organizes data into four object types. Understanding these types demystifies everything else.
Blobs are the simplest: they're files. A blob is just the raw content of a file plus a header, hashed with SHA-1 to create a unique identifier. Two files with identical content have identical blob hashes, anywhere in the repository. Git never stores duplicate content; it reuses the same blob object.
Trees are directories. A tree is a listing of blobs and other trees with their names and permissions. When you see a folder structure in your repository, that's represented by a tree object. Each tree points to its children (other trees or blobs) by their hash.
Commits are where the narrative lives. A commit object points to:
- A tree (the snapshot of your repository at that moment)
- Its parent commit(s) (linking it to history)
- Metadata: author, committer, timestamp, message
Tags are named references to commits, usually for marking releases.
Let's make this concrete. You create a tiny repository:
$ mkdir demo && cd demo && git init
$ echo "hello" > file.txt
$ git add file.txt
$ git commit -m "Initial commit"Under the hood, Git created:
- A blob containing
"hello\n"with hashce013625030...(truncated) - A tree listing
file.txtpointing to that blob, with hash65a9a3da8e... - A commit pointing to that tree, with hash
f8f9a3b1c2...(your commit hash will differ)
When you run git cat-file -p <hash>, you can inspect these objects directly:
$ git cat-file -p f8f9a3b1c2
tree 65a9a3da8e...
author You <you@example.com> 1234567890 +0000
committer You <you@example.com> 1234567890 +0000
Initial commitThis structure explains why Git is distributed and reliable. Every object is identified by its content. The entire history is a directed acyclic graph (DAG) of commits, each pointing to a parent and a tree. To verify the integrity of the repository, Git can hash every object and confirm they match their identifiers—corruption is detectable and localized.
Branches are pointers — A branch is a file containing a commit id
This is where Git's true power becomes apparent: a branch is not a container or a copy. It's a file containing a commit hash.
When you run git branch feature, Git creates a file at .git/refs/heads/feature. Open it:
$ cat .git/refs/heads/feature
a3f9e2c4b1d5e7f8a9b2c3d4e5f6a7b8c9d0e1f2That's it. A branch is a 41-byte text file containing a commit hash. When you run git commit on that branch, Git updates the file to point to the new commit. When you merge, Git reads the commit hash from both branches and combines their histories.
This design means:
- Branching is instant—creating a file is cheap
- Branches don't consume storage—they're pointers, not copies
- Merging is based on content, not branch names—what matters is the commit graph, not the branch identity
Consider a production scenario: you're on main, and you create three feature branches, each with fifty commits. That's 150 new commits in the object store, but only three tiny text files for the branch pointers. When you merge feature-1 back to main, Git doesn't copy those commits; it updates the main pointer to include feature-1's history.
Remote branches work the same way. When you fetch from GitHub, Git updates files in .git/refs/remotes/origin/. origin/main is just a file tracking where main was on the remote last time you synchronized.
HEAD — Points to the current branch or commit
HEAD is a special reference: it points to your current location in the repository. Most of the time, HEAD points to a branch (which itself points to a commit). When you run git checkout main, you're actually updating HEAD to point at the branch main.
Look at its contents:
$ cat .git/HEAD
ref: refs/heads/mainIt's a file saying "I'm pointing to the branch main." When you commit, Git finds what HEAD points to, follows the reference chain, and updates that commit pointer.
In rare cases, HEAD can point directly to a commit (called detached HEAD state). This happens when you run git checkout <commit-hash>:
$ git checkout a3f9e2c
$ cat .git/HEAD
a3f9e2c4b1d5e7f8a9b2c3d4e5f6a7b8c9d0e1f2Now HEAD is detached. If you commit here, you create a commit that no branch points to. It still exists in the object store, but you can lose it if you switch branches without saving its hash. Understanding this is crucial for recovering work.
Many "Git accidents" happen because developers don't understand what HEAD is doing. For example, git reset --hard HEAD~1 means "point my current branch to its parent commit and discard working changes." It's not destructive to the object store (that commit still exists), but if you've pushed that commit to GitHub, you're rewriting history.
Common mistakes
Thinking branches are separate copies: They're not. A branch is a pointer. When you merge feature into main, you're not copying commits; you're rewiring pointers. The commits were always part of the same object store.
Assuming detached HEAD is dangerous: It's unusual, not dangerous. The commits you create in a detached HEAD state exist until garbage collection runs (usually after 30 days). You can recover them with git reflog.
Misunderstanding rebase vs. merge: Both achieve the same end result: combining histories. Merge creates a new commit linking two parent commits; rebase rewrites the commit graph to appear linear. The underlying objects (blobs, trees) remain unchanged—only the commit graph structure differs.
Force-pushing without understanding consequences: git push --force rewrites history on the remote. If your team has fetched your commits, they'll have conflicts. Force-push is necessary sometimes (e.g., fixing a commit you haven't pushed), but it's a scalpel, not a hammer.
Key takeaways
- Git stores complete snapshots of your project, not diffs. Each commit is an independent, recoverable state.
- Four object types power Git: blobs (files), trees (directories), commits (snapshots with metadata), and tags (named references). Everything else builds on these.
- Branches are pointers—tiny files containing commit hashes. This is why branching is instant and why distributed workflows are possible.
- HEAD points to your current location. It usually references a branch, which references a commit. Understanding HEAD's indirection prevents "lost commit" confusion.
- The object store is forgiving. Deleted commits are recoverable via
git refloguntil garbage collection. Work is rarely truly lost. - Knowing the underlying model transforms Git from a tool you use to a tool you understand. Debugging, recovery, and strategy all become clearer.






Comments
Loading comments…