How Git Works Internally: Commits, Trees, and Blobs

Git is the version control system almost every developer uses. Most people learn a handful of commands and stop there. That works until a rebase goes wrong, a detached HEAD appears, or a merge conflict looks like noise instead of a data model.

This article explains how Git works internally: how Git stores files, how a commit is built, why a branch is only a pointer, and what happens when you run git add and git commit.

Why Developers Should Understand Git Internals

Git is not a folder of diffs. It is a content-addressable object database plus a small set of pointers.

That model shows up in real work:

  • Recovering a commit after a bad reset
  • Understanding why a rebase rewrites history
  • Reading git log, git status, and merge conflicts without guessing
  • Using pull requests, CI, and code review with confidence

Once the object model is clear, the commands stop feeling like magic.

Simple Explanation

Think of Git as a warehouse that stores snapshots of your project, not a tape that records every keystroke.

Each time you commit, Git stores the file contents that changed, a tree that describes the directory structure, and a commit object that points to that tree and to parent commits.

Every stored object is named by a SHA-1 hash of its content. Newer repositories can use SHA-256. Same content produces the same hash. That is why Git can detect identical files instantly and why changing one byte produces a new object.

A branch is not a copy of the project. It is a movable label that points at one commit. HEAD is the label that says which branch or commit you are on right now.

How It Works Internally

The three areas of a Git repository

A working Git repo has three layers: the working tree (files you edit), the index or staging area (.git/index), and the object database (.git/objects).

git add copies content from the working tree into the index as blobs. git commit freezes the index into tree and commit objects and moves the current branch pointer.

The four object types

  • Blob: raw file content. A blob has no filename. The name lives in the tree that points to it.
  • Tree: a directory listing. Each entry has a mode, a name, and a hash of a blob or another tree.
  • Commit: metadata including tree hash, parent commit hashes, author, committer, timestamp, and message.
  • Tag: an annotated tag object that points to a commit. Lightweight tags are just refs, not objects.

An object is stored as type, size, a null byte, and content, then compressed with zlib, then named by the hash of that header plus content.

Data flow of a first commit

You edit src/app.js. git add hashes the file bytes, writes a blob, and updates the index entry. git commit builds tree objects from the index, writes a commit object pointing at the root tree, and moves the current branch ref to the new commit hash.

How a tree represents a project

If the repo has README.md and src/app.js, the commit points at a root tree. That tree points at a blob for README.md and a subtree for src. The src tree points at a blob for app.js.

If you later change only app.js, Git writes a new blob, a new src tree, a new root tree, and a new commit. README.md keeps the same blob hash. Unchanged content is reused, not copied.

Commits form a directed acyclic graph

Each commit points to one or more parents. A linear history is a chain. A merge commit has two or more parents. That graph is the real history of the project.

In a merge, the first parent is usually the branch you were on. The second parent is the branch you merged.

Refs, branches, and HEAD

Refs are files under .git/refs or packed in .git/packed-refs. .git/refs/heads/main contains one commit hash. .git/HEAD usually contains ref: refs/heads/main. Remote-tracking branches live under .git/refs/remotes/. Tags live under .git/refs/tags/.

When you create a branch, Git writes a new ref file. It does not copy files. When you commit on that branch, Git writes a new commit and updates only that ref.

Detached HEAD means HEAD points at a raw commit hash instead of a branch name. New commits will not move any branch until you attach a branch again.

What git add and git commit actually do

git add reads the working-tree file, creates a blob if needed, and updates the index so the path points at that blob hash. The next commit is built from the index, not directly from the working tree. That is why you can edit a file, stage it, edit it again, and still commit the staged version.

git commit writes tree objects that match the index, creates a commit object whose tree is the root tree and whose parent is the current HEAD commit, and updates the branch that HEAD points to.

How Git compares files so quickly

The index stores each path with its blob hash and filesystem metadata such as size and modification time. git status can often skip reading file contents if the metadata still matches. When metadata differs, Git hashes the working-tree file and compares hashes.

Packfiles, reflog, and garbage collection

Loose objects are convenient but wasteful. git gc and automatic maintenance pack objects into packfiles. Packfiles use delta compression so similar objects store only the difference from a base object.

The reflog in .git/logs records where refs used to point. That is why git reflog can recover commits that no longer sit on any branch, until garbage collection removes unreachable objects.

Real-World Examples

A feature branch in a web app is just another pointer. You change an API handler and open a pull request. The host shows a diff, but the repository still only stores blobs, trees, and commits. Merging joins two chains in the graph.

git rebase creates new commit objects with new hashes. The old commits still exist until they become unreachable. A force-push moves the remote branch pointer to the new chain. Anyone who based work on the old hashes now has a divergent graph.

A CI system checks out one commit hash. The commit object names a tree, and that tree names every blob needed to reconstruct the project at that snapshot.

If you run git reset --hard after a commit, git reflog can find the old hash. If you never added the files, Git never stored blobs, so recovery is not a Git problem.

Code Examples

Inspect the current commit:

git rev-parse HEAD
git cat-file -p HEAD

git cat-file -p pretty-prints an object. For a commit you will see the tree, parents, author line, and message.

Inspect the root tree:

git cat-file -p HEAD^{tree}

Each line is a mode, type, hash, and filename.

Hash a file the way Git does:

git hash-object src/app.js
git hash-object -w src/app.js

The first command prints the blob hash. The second also writes the blob into .git/objects.

Compare the three areas:

git status
git diff
git diff --cached

git diff compares working tree to index. git diff --cached compares index to HEAD.

Recover a commit:

git reflog
git branch recover-work COMMIT_HASH
git checkout recover-work

Common Misconceptions

Git stores snapshots, not a chain of diffs as its logical model. Packfiles may compress objects with deltas for disk efficiency.

A branch is not a copy of the project. It is a hash pointer. Creating a branch is cheap because no files are duplicated.

HEAD is not always the latest commit on main. HEAD is whatever you have checked out.

git add is not a permanent save of project history. Staging writes a blob, but only a commit records that blob through a tree. Unreferenced blobs can still be collected later.

Amending or rebasing creates a new commit object with a new hash. Downstream clones still hold the old hash until they update their refs.

Deleting a branch removes a pointer. Commits remain until no ref or reflog entry reaches them and garbage collection runs.

Best Practices and Key Takeaways

  • Commit snapshots that represent a complete thought so the graph stays readable.
  • Stage deliberately. The index is a real snapshot, not a checkbox list.
  • Treat hashes as identity. If the hash changed, Git considers it a different object.
  • Use branches for isolation. They are pointers, so they are cheap.
  • Before reset --hard, rebase, or force-push, note the current hash or create a backup branch.
  • Use git reflog before assuming work is gone.
  • Avoid force-pushing shared branches. You are moving a public pointer.
  • Learn git cat-file, git rev-parse, and git ls-tree to inspect the database directly.

FAQ

What is a Git object?

A Git object is a stored blob, tree, commit, or tag, addressed by a hash of its contents. The object database under .git/objects is the core of the repository.

What is the difference between a blob and a file?

A blob is file content only. The filename and permissions are stored in a tree entry that points at the blob.

Why does every commit have a hash?

The hash is the object ID. It is computed from the commit contents, including the tree hash, parents, author data, and message. Any change produces a new ID.

What does detached HEAD mean?

It means HEAD points directly at a commit instead of a branch name. New commits will not update a branch until you create or check out a branch.

Does Git store the entire project in every commit?

Logically yes: each commit points at a full tree. Physically, unchanged blobs and trees are reused by hash.

What is the staging area?

The staging area (index) is the snapshot Git will turn into the next commit. git add updates it. git commit freezes it into tree and commit objects.

How can I recover a commit after git reset --hard?

Run git reflog, find the hash from before the reset, and create a branch at that hash. Recovery works if Git had stored the objects.

Is Git still using SHA-1?

Default repositories still use SHA-1 object IDs. Git added SHA-256 support for new repositories. The object model is the same: content in, hash out, refs point at hashes.

Related Articles

Git becomes predictable when you treat it as an object database with moving labels. Learn the four object types, the three areas, and the commit graph. The commands then map onto a model you can inspect.

Next Post Previous Post