Skip to main content
← Back to course

Git Internals: Objects, Refs & the DAG

The beginner track calls a commit a "save point." That mental model is fine until it leaks: you hit a detached HEAD, a force-push that "lost" nothing, or a git reset that left files untouched — and the save-point metaphor stops explaining what you're looking at. Underneath, Git is a content-addressable key-value store with a directed acyclic graph (DAG) layered on top. Once you see that, every confusing command becomes predictable.

Four object types, one hash

Everything Git stores is an object, named by the SHA of its contents. There are four kinds:

  • blob — the raw bytes of one file. No name, no path.
  • tree — a directory listing that maps names to blobs and other trees.
  • commit — a snapshot pointer: one root tree, zero-or-more parents, author, message.
  • tag — an annotated, signed pointer to another object.

Because the name is the content hash, identical files anywhere in history are stored exactly once. A commit doesn't store a diff — it stores a pointer to a full tree. Diffs are computed on demand by walking parent trees.

Prove it on this repo. From the project root:

# The commit object for HEAD
git cat-file -p HEAD
# -> tree <sha>, parent <sha>, author..., message

# Follow the tree to see name -> blob mappings
git cat-file -p HEAD^{tree}
# -> 100644 blob <sha>  package.json
#    040000 tree <sha>  app

Walk app/ and you'll eventually hit a blob whose contents are literally app/layout.tsx. No magic — just hashes pointing at hashes.

A left-to-right flow showing how a Git commit points to a root tree, which points to subtrees, which point to a blob of file bytes.

Refs are the human layer

Object SHAs are unusable by hand, so Git keeps refs: mutable names that point at a commit SHA. A branch like main is one line of text in .git/refs/heads/main (or packed in packed-refs). HEAD is a ref that usually points at another ref — that indirection is the entire concept of "the current branch."

cat .git/HEAD            # ref: refs/heads/main
git rev-parse main       # the commit SHA main resolves to
git symbolic-ref HEAD    # refs/heads/main

"Detached HEAD" just means HEAD holds a raw SHA instead of pointing at a branch ref. Nothing is broken; you're standing on a commit with no name above you.

The DAG, and why it changes how you think

Commits form a DAG through their parent links. A merge commit has two parents; a normal commit has one. This is why git log feature ^main (commits reachable from feature but not main) is cheap — it's set arithmetic over a graph. On this stack, your CI on Vercel resolves a deployment to one commit SHA, and a Supabase migration history is meaningful only because each commit's tree is immutable.

The payoff for production work: branches are disposable, commits are durable. git reset --hard moves a ref and leaves the old commits orphaned but intact — recoverable from git reflog until garbage collection. A force-push rewrites which SHA a ref names; it never edits an existing object. Knowing that, "I lost a commit" almost always means "I lost the ref pointing at it," which reflog fixes.

Pitfalls

  • Treating reset/revert/checkout as interchangeable. reset moves a ref, revert writes a new inverse commit, checkout/switch repoints HEAD. Different targets entirely.
  • Force-pushing a shared branch. You don't destroy objects, but you orphan everyone else's refs. Rewrite only branches you own.
  • Assuming GC is immediate. Orphaned objects linger; never rely on that for security. A leaked secret in any reachable commit is leaked.

⌨️ Exercise

In a throwaway clone of this repo, run git cat-file -p HEAD, copy the tree SHA, and git cat-file -p <tree> until you reach a blob for a real source file — confirm its SHA matches git rev-parse HEAD:<path>. Then commit a trivial change, run git reset --hard HEAD~1, and recover the "lost" commit using only git reflog and git reset --hard <sha>. Write one sentence explaining why no object was ever actually deleted.