Git’s command line is famously confusing. The data model underneath is not. It’s a small content-addressed key-value store with exactly four object types. Once you can see the objects, commands like reset, checkout, and rebase stop being incantations and become obvious manipulations of a graph.

Content Addressing: The Hash Is the Key

Git stores everything by the hash of its content. Hash the bytes, and that hash is the address:

bash
echo 'hello' | git hash-object --stdin
# ce013625030ba8dba906f756967f9e9ca394464a

Same content always produces the same hash, so identical files are stored once. Change one byte and you get a completely different address — which is how Git detects any change and why history is tamper-evident.

The Four Objects

Everything in .git/objects is one of four types:

text
   blob    → file contents (no name, no metadata, just bytes)
   tree    → a directory: names + modes → blob/tree hashes
   commit  → a snapshot: one tree + parent(s) + author + message
   tag     → an annotated pointer to an object

They compose into the snapshot of your project:

text
   commit ──▶ tree ──▶ blob   (README.md)
                  └──▶ tree ──▶ blob   (src/main.c)
              parent


            commit ──▶ tree ──▶ ...

A commit doesn’t store a diff — it points at a tree, a full snapshot of every file at that moment. Unchanged files reuse the same blob hashes across commits, so snapshots are cheap despite being complete.

Commits Are a Linked List

Each commit records its parent(s), forming a directed graph back through history:

text
   A ◀── B ◀── C ◀── D   (main)

               └── E ◀── F   (feature, branched at C)

A merge is just a commit with two parents. There’s no special “branch history” structure — branches are derived by walking parent pointers.

Refs: Branches Are Just Pointers

A branch isn’t a copy of anything. It’s a 40-character file containing the hash of one commit:

bash
cat .git/refs/heads/main
# d4f7a... (the tip commit of main)

Creating a branch writes one tiny file. That’s why branching is instant. HEAD points at the current branch; committing just advances the branch’s pointer to the new commit. Now the commands fall out:

  • git reset moves a branch pointer to a different commit.
  • git checkout/switch moves HEAD and updates your working tree to that tree’s blobs.
  • git rebase replays commits onto a new parent, creating new commit objects.

Takeaways

  • Git is a content-addressed store: an object’s address is the hash of its content, so identical content is deduplicated and history is tamper-evident.
  • Four object types — blob, tree, commit, tag — compose into full snapshots, not diffs.
  • Commits link to parents to form the history graph; a merge is a two-parent commit.
  • Branches and HEAD are just pointers to commits, which is why branching and reset are instant.