Git Internals

How blobs, trees, and commits actually work under .git, and what git reset really does to HEAD, the index, and the working directory.

Why this is worth knowing

Every previous page in this track treated .git as a black box that just works. It's actually a surprisingly small, simple database, and understanding what's really inside it demystifies a lot of behavior that otherwise feels like memorized magic — why git reset --soft versus --hard behave so differently, why renaming a file is "free" for Git, and why two files with identical content only ever take up storage once.

The object database: everything is one of four things

Everything Git tracks — file contents, directory structure, commits, even tags — is stored as an object inside .git/objects/, and every object is exactly one of four types.

Blobs — file content, with no filename attached

A blob stores a file's raw content and nothing else — no filename, no permissions, no path. That's a deliberate design choice: content is decoupled entirely from where it lives, which is exactly why renaming a file costs Git nothing — the blob storing the content doesn't change at all, only the tree entry pointing at it does.

Bash
$ echo "Hello World" | git hash-object --stdin -w
557db03de997c86a4a028e1ebd3a1ceb225be238

git hash-object computes the SHA-1 hash of "blob <byte-length>\0<content>" and, with -w, writes it into the object database. That hash is the object's permanent identity — identical content always produces the identical hash, which is why Git is described as content-addressable: two files anywhere in the repository with exactly the same content are automatically stored as the same one blob, no deduplication logic required.

Bash
$ git cat-file -t 557db03de997c86a4a028e1ebd3a1ceb225be238
blob
$ git cat-file -p 557db03de997c86a4a028e1ebd3a1ceb225be238
Hello World

git cat-file -t reports an object's type; -p pretty-prints its content — both work on any object hash, and they're the single most useful pair of commands for actually looking inside .git rather than taking it on faith.

Trees — a directory listing

A tree object represents one directory's contents: a list of entries, each with a file mode, a type (blob for a file, tree for a subdirectory), a name, and the hash of that entry's own object.

Bash
$ git cat-file -p HEAD^{tree}
100644 blob 557db03de997c86a4a028e1ebd3a1ceb225be238    README.md
100644 blob a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0    composer.json
040000 tree b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1    app

Reading that last column carefully: app isn't a blob at all, it's a tree — meaning that hash points to another tree object describing the app/ directory's own contents, which in turn can point to more blobs and more nested trees. A full snapshot of the entire project at one point in time is really just one root tree object, recursively pointing to every file and subdirectory beneath it.

Commits — a snapshot pointer plus metadata

A commit object is small: a pointer to exactly one tree (the repository's complete state at that moment), a pointer to its parent commit(s), and the author, committer, timestamp, and message.

Bash
$ git cat-file -p HEAD
tree b6f4a1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8
parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
author Ali Raza <ali@example.com> 1755678600 +0500
committer Ali Raza <ali@example.com> 1755678600 +0500

Add password reset flow

This is the entire explanation for something that trips people up constantly: a commit does not store a diff. It stores a pointer to a complete tree — the diff you see in git show or git log -p is computed on the fly by comparing that commit's tree against its parent's tree; nothing about a "diff" is stored on disk at all. A merge commit is simply a commit object with two (or more) parent lines instead of one, which is the literal, on-disk reason it's described as having "two parents."

Plaintext
commit a1b2c3d           commit b2c3d4e (child)
┌─────────────┐          ┌─────────────┐
│ tree ──────┼──►[tree]  │ tree ──────┼──►[tree]
│ parent: -- │          │ parent: a1b2c3d
│ "Initial"  │          │ "Add login"│
└─────────────┘          └─────────────┘

A branch (refs/heads/main) and a tag are, at the storage level, nothing more than a plain text file containing one commit hash — which is exactly why creating a branch is instantaneous regardless of repository size: it's writing one line to one small file, not copying anything.

Bash
$ cat .git/refs/heads/main
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Where objects actually live on disk

Bash
$ find .git/objects -type f
.git/objects/55/7db03de997c86a4a028e1ebd3a1ceb225be238

Every object is stored under .git/objects/<first 2 hash chars>/<remaining 38 chars>, zlib-compressed — the two-character split just keeps any one directory from accumulating an unmanageable number of files as a repository grows. git gc periodically repacks loose objects like this into more storage-efficient packfiles, but the logical model — blob, tree, commit, all addressed by content hash — stays exactly the same either way.

What git reset actually does

Git maintains three separate things that can each hold a different view of the project at any moment: HEAD (which commit your branch currently points to), the index/staging area (what's queued for the next commit), and the working directory (the actual files on disk). git reset has three modes, and the difference between them is entirely about how many of those three it touches.

Bash
git reset --soft  <commit>   # moves HEAD only
git reset --mixed <commit>   # moves HEAD + resets the index          (default)
git reset --hard  <commit>   # moves HEAD + resets the index + working directory
HEAD (branch pointer) Index (staged changes) Working directory (files on disk)
--soft Moved to <commit> Unchanged — still reflects the old commit Unchanged
--mixed (default) Moved to <commit> Reset to match <commit> Unchanged
--hard Moved to <commit> Reset to match <commit> Reset to match <commit>uncommitted work is discarded

Concretely, given three commits where you want to undo the last one but keep working on its changes:

Bash
$ git log --oneline
c3d4e5f Add password reset flow      <- HEAD, main
b2c3d4e Add login form
a1b2c3d Initial commit

$ git reset --soft HEAD~1

After --soft, main now points at b2c3d4e — but the index still contains every change from the "undone" commit, staged and ready. Running git status shows those changes as staged, and git commit immediately would recreate essentially the same commit. This is the mode for "I want to undo the commit boundary, not the work" — folding the last commit's changes back in to be combined with something else before re-committing.

--mixed (what plain git reset HEAD~1 does with no flag) goes one step further: the index is also reset to match the older commit, so those same changes now show up as unstaged modifications instead of staged ones — the work is still on disk, you'd just need git add again before committing.

--hard is the one to genuinely be careful with: the working directory itself is overwritten to match the target commit, so any uncommitted changes — staged or not — are gone, not just unstaged. There's no prompt, no confirmation, and no trash can.

The safety net: git reflog

Even after a --hard reset seemingly "destroys" a commit, the commit object itself usually isn't deleted immediately — Git just stops any branch from pointing at it. git reflog keeps a local log of everywhere HEAD has pointed recently, which is exactly how an "accidental" --hard reset is normally recovered:

Bash
$ git reflog
b2c3d4e HEAD@{0}: reset: moving to HEAD~1
c3d4e5f HEAD@{1}: commit: Add password reset flow
b2c3d4e HEAD@{2}: commit: Add login form

$ git reset --hard c3d4e5f      # bring the "lost" commit straight back

The commit was never actually gone — only unreferenced by any branch — and stays recoverable via its hash until Git's garbage collector eventually cleans up genuinely unreferenced objects, which by default doesn't happen for weeks.

Common mistakes

  • Believing a commit stores a diff — it stores a complete snapshot (a tree), and the diff shown by git show/git log -p is computed on the fly by comparing two snapshots; nothing about "what changed" is stored on disk as such.
  • Running git reset --hard without realizing it discards uncommitted changes in the working directory too, not just resetting which commit is checked out — always check git status for anything unsaved first.
  • Assuming a "deleted" commit after a hard reset is unrecoverable — as long as its hash is known (from git reflog, a teammate's message, or a note), the object usually still exists until garbage collection eventually removes it.
  • Confusing --soft and --mixed — the practical difference only shows up in whether the undone commit's changes land in the staging area (--soft) or the working directory as unstaged edits (--mixed, the default) after the reset.