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. You will see how Git stores files, how a commit is built, why a branch is only a pointer, and what actually 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 everywhere real work happens:

  • 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 (or were new)
  • A tree that describes the directory structure
  • 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 (Git also supports SHA-256 in newer repositories). 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:

Working tree
    |
    |  git add
    v
Index (staging area)
    |
    |  git commit
    v
Object database (.git/objects)

Working tree is the files you edit in the project folder.

Index (also called the staging area) is a snapshot Git is preparing for the next commit. It lives in .git/index.

Object database is the permanent store. Loose objects live under .git/objects/ab/cdef.... Later Git packs them into efficient packfiles.

The four object types

Almost everything Git stores is one of four objects:

  • 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: 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 + null byte + 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
        |
        v
git add src/app.js
  - hash the file bytes
  - write a blob object
  - update the index entry for src/app.js
        |
        v
git commit -m "Add app.js"
  - build tree objects from the index
  - write a commit object pointing at the root tree
  - move the current branch ref to the new commit hash

How a tree represents a project

Suppose the repo looks like this:

README.md
src/
  app.js

Git stores something equivalent to:

commit abc123
  tree t0
    blob b1  README.md
    tree t1  src
      blob b2  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.

A -- B -- C -- E   (main)
       \      /
        D --    (feature merged)

E is a merge commit. Its first parent is usually the branch you were on. Its 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 actually does

git add does not only “mark a file.” It:

  1. Reads the working-tree file
  2. Creates a blob if that content is not already stored
  3. 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.

What git commit actually does

git commit:

  1. Writes tree objects that match the index
  2. Creates a commit object whose tree is the root tree and whose parent is the current HEAD commit
  3. Updates the branch that HEAD points to

Nothing in the working tree needs to change for a commit to succeed, as long as the index is valid.

How Git compares files so quickly

The index stores each path with its blob hash and filesystem metadata (size, 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. Equality of hashes means equality of content.

Packfiles and garbage collection

Loose objects are convenient but wasteful. git gc and automatic maintenance pack objects into packfiles. Packfiles use delta compression: similar objects store only the difference from a base object. Unreachable objects (for example after a hard reset, once the reflog expires) can be pruned.

The reflog (.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 them.

Real-World Examples

A feature branch in a web app

You branch from main, change an API handler, and open a pull request. GitHub shows a diff, but the repository still only stores blobs, trees, and commits. The pull request is a request to create a merge commit (or a squash commit) that joins two chains in the graph.

Why force-push rewrites history

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. That is a pointer problem, not a mysterious “cloud sync” issue.

CI pipelines

A CI system checks out one commit hash. It does not need your branch name to build. The commit object is enough: it names a tree, and that tree names every blob needed to reconstruct the project at that snapshot.

Accidental deletion

You run git reset --hard and think work is gone. If you committed it, the commit object is still in the database. git reflog finds the old hash. Checking out that hash restores the tree. If you never added the files, Git never stored blobs, so recovery is not a Git problem.

Code Examples

These commands inspect the object database. Run them in any Git repo.

See HEAD and 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. Follow a tree hash the same way to walk into a directory.

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.

See what is staged versus committed

git status
git diff
git diff --cached

git diff compares working tree to index. git diff --cached compares index to HEAD. That mapping is the three-area model in practice.

Find a “lost” commit

git reflog
git checkout <commit-hash>

Prefer creating a branch at that hash instead of staying detached:

git branch recover-work <commit-hash>
git checkout recover-work

Common Misconceptions

“Git stores diffs.” Git stores snapshots. Packfiles may compress objects with deltas for disk efficiency, but the logical model is complete trees at each commit.

“A branch is a copy of the project.” A branch is a 40-character (or 64-character) pointer. Creating a branch is cheap because no files are duplicated.

“HEAD is my latest commit on main.” HEAD is whatever you have checked out. It might be another branch or a detached commit.

“git add saves my work permanently.” Staging writes a blob, which is durable, but only a commit records that blob in history through a tree. Unreferenced blobs can still be collected later.

“Changing a commit message rewrites nothing important.” 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 deletes the commits.” 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 compile or at least represent a complete thought. The graph is easier to read when commits are meaningful units.
  • 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, even if the message looks similar.
  • Use branches for isolation. They are pointers, so they are cheap. Long-lived divergence is a collaboration problem, not a storage problem.
  • Before a destructive command (reset --hard, rebase, 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 that other graphs already used as a parent.
  • Learn git cat-file, git rev-parse, and git ls-tree. They expose the database the friendly commands hide.

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, so disk use grows with changes, not with a full copy each time.

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 only if Git had stored the objects (usually because you committed, or at least added, the files).

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 either way: 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, instead of a list you have to memorize.

Next Post Previous Post