Most developers learn git as a sequence of commands: add, commit, push, pull. This works until something goes wrong — then you're desperately googling "how to undo the last 3 commits without losing changes" and hoping the Stack Overflow answer matches your exact situation.
The developers who never panic when git does something unexpected have one thing in common: they understand the object model. Once you see git as what it actually is — a content-addressable key-value store — commands like git reset, git rebase, and git reflog become logical rather than magical.
The object store
Every git repository has a hidden .git/objects/ directory. This is the object database. When git stores anything — file contents, directory listings, commit metadata — it writes it as a compressed binary file in this directory, using the SHA-1 hash of the content as the filename.
# Create an empty repo and make a commit
git init demo && cd demo
echo "hello, git" > README.md
git add README.md
git commit -m "initial commit"
# Look at what's in the object store
find .git/objects -type f | sort
# Output (your hashes will differ):
# .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
# .git/objects/45/8e9a40ffe2d89f79bff61a0a60b94fa5edba73
# .git/objects/9a/e3bb4b43c0e16ab13f60acbb5d54f4f5c2afcd
Three objects for one file and one commit. They correspond to the three fundamental object types: blob, tree, and commit.
Blobs: file contents
A blob stores raw file content, nothing else — no filename, no permissions, no metadata. To inspect it:
git cat-file -t 3b18e512 # type: blob
git cat-file -p 3b18e512 # contents: "hello, git"
The SHA-1 of a blob is computed over a header plus the content:
import hashlib
def git_blob_sha(content: bytes) -> str:
header = f"blob {len(content)}\0".encode()
return hashlib.sha1(header + content).hexdigest()
print(git_blob_sha(b"hello, git\n"))
# → 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
This content-addressing means identical files share a single blob object. If you have 1000 copies of the same config file across your repo's history, git only stores it once. The deduplication is implicit and total.
Trees: directory listings
A tree object is essentially a directory listing. It maps filenames (and their permissions) to object hashes:
git cat-file -p 458e9a40
# Output:
# 100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad README.md
Trees can contain other trees (representing subdirectories). The root tree for a commit is a complete, recursive snapshot of your entire working directory at the moment of the commit.
This is important: git does not store diffs. It stores snapshots. Each commit points to a complete tree representing the full state of the repository. The "diff" you see in git show is computed on the fly by comparing the tree from the commit with the tree from its parent.
Commits: metadata + pointer
A commit object ties everything together:
git cat-file -p HEAD
# Output:
# tree 458e9a40ffe2d89f79bff61a0a60b94fa5edba73
# author Alex Chen <alex@example.com> 1716374400 +0000
# committer Alex Chen <alex@example.com> 1716374400 +0000
#
# initial commit
A commit contains: a pointer to the root tree, a pointer to parent commit(s), author/committer info, timestamps, and the commit message. For the first commit, there's no parent. For a merge commit, there are two parents.
The commit chain is a linked list: each commit points to its parent. Traversing this list from HEAD backwards gives you the entire history. A branch is just a text file containing the SHA-1 of the commit it points to:
cat .git/refs/heads/main
# → 9ae3bb4b43c0e16ab13f60acbb5d54f4f5c2afcd
cat .git/HEAD
# → ref: refs/heads/main
Why this matters for everyday git
Understanding the object model makes git operations intuitive:
git reset --soft HEAD~1 moves the branch pointer back one commit but leaves the tree and index unchanged. You've "uncommitted" but kept your files staged. The original commit object still exists in the object store — it just has no branch pointing to it.
git reflog works because every time HEAD moves, git writes an entry. Even after git reset --hard "destroys" commits, you can find the old SHA-1s in the reflog and restore them — because the objects are still in the store until garbage collection.
Rebasing creates new commit objects with the same content but different parents (and therefore different SHA-1s). The old commits still exist; git reflog can reach them. This is why "force push after rebase" is dangerous on shared branches — you're not modifying history, you're replacing it with new objects and abandoning the old ones.
Cheap branching makes sense now: a branch is just a 40-character SHA-1 in a text file. Creating a branch is an O(1) operation — write a file, done. The "divergence" only exists in the commit graph, not in any duplication of objects.
git cat-file -p HEAD, then follow the tree hash, then follow a blob hash. You've just traversed the entire data structure that represents your last commit. Nothing in git is magic — it's all SHA-1 pointers in a key-value store.
The next time git does something confusing, ask: what objects exist, and what are the pointers pointing at? The answer is always in .git/.