Git Fundamentals

What Git actually stores

The single most useful mental model shift: Git does not store diffs between versions (that's how older systems like SVN worked). Every commit stores a full snapshot of the entire project at that point — efficiently, because identical file content is only ever stored once (deduplicated by content hash), but conceptually it's a complete snapshot, not an incremental change.

graph TD c1["Commit 1<br/>(message, author, parent: none)"] --> t1["Tree<br/>(root directory listing)"] t1 --> b1["Blob: main.go"] t1 --> b2["Blob: README.md"] c2["Commit 2<br/>(parent: Commit 1)"] --> t2["Tree (new snapshot)"] t2 --> b1 t2 --> b3["Blob: main.go (v2, changed)"] c2 -.->|"parent pointer"| c1

Three object types: a blob is raw file content (no filename — just bytes, identified by the hash of those bytes). A tree is a directory listing — filenames mapped to blob or sub-tree hashes. A commit points to one tree (the project's complete state at that moment) plus its parent commit(s) plus metadata (author, message, timestamp). Note in the diagram: main.go's blob is unchanged between commits, so Commit 2's tree points at the same blob object as before for the unchanged file (README.md) — Git only creates new objects for content that actually changed, which is the deduplication that makes "full snapshot every commit" cheap in practice rather than exponentially wasteful.

Branches are just pointers

A branch is not a container that holds commits — it's a small file containing one commit hash, and it's called "the tip of the branch." HEAD is (usually) a pointer to the current branch, which points to a commit.

graph LR main["main (branch pointer)"] --> c3["Commit 3"] feature["feature (branch pointer)"] --> c4["Commit 4"] c3 --> c2["Commit 2"] c4 --> c2 c2 --> c1["Commit 1"] head["HEAD"] -.->|"currently checked out"| feature

git commit creates a new commit object (pointing at the current branch's tip as its parent), then moves the current branch's pointer forward to the new commit — that's the entire operation. This is why creating a branch (git branch feature) is instant regardless of repository size: it's writing one small file containing a commit hash, not copying any project files.

Worked example: what git commit actually does

Starting state: main points at Commit 2. Working directory has one changed file. git add computes that file's blob hash and stages it (records it in the index, a staging-area file). git commit:

  1. Builds a new tree object from the index — a snapshot of what every file's blob hash currently is, unchanged files reusing existing blob objects.
  2. Creates a new commit object: parent = Commit 2 (main's current tip), tree = the new tree just built, plus message/author/timestamp.
  3. Moves main's pointer to this new commit.

Nothing here is a "diff computed and stored" — the diff shown by git show or git log -p is computed on demand, by comparing two trees, not stored as part of the commit itself.

Common pitfall

Treating a commit hash as if it identifies "a change" rather than "an entire project state" leads to real confusion when reasoning about git diff, git revert, or git cherry-pick — all of them work by comparing or replaying full snapshots, and understanding that is what makes their actual behavior (and their failure modes, like conflicts) predictable instead of mysterious. See Branching and Merging for where this snapshot model directly explains what merge and rebase are doing.