DEV Community

Cover image for How Git Actually Works Under the Hood
lui were
lui were

Posted on

How Git Actually Works Under the Hood

Most developers learn Git the same way: a handful of commands memorized just well enough to get code onto GitHub without breaking anything. git add, git commit, git push. Maybe git pull when things go out of sync. And then, inevitably, the moment that every developer remembers — the first time a merge conflict appears, or a branch gets accidentally deleted, or a rebase goes sideways — and suddenly the memorized commands aren't enough anymore.

The reason Git feels mysterious when things go wrong is that most developers learn the surface (the commands) without ever learning the model underneath. Git isn't magic. It's a surprisingly elegant data structure — a content-addressed, append-only graph — and once you understand that structure, the commands stop being incantations you've memorized and start being logical operations on a system you actually understand. Conflicts become explainable. Rebases become predictable. Even the most mangled repository state becomes recoverable.

This article goes under the hood. By the end, you'll understand what Git is actually storing when you run git commit, how branches really work, and why understanding the internals makes you dramatically better at using the tool.

The Core Idea: A Content-Addressed Store

Git's foundation is a simple but powerful idea: it stores everything by the hash of its content.

When you add a file to Git, Git doesn't store the filename. It takes the file's content, computes a SHA-1 hash of it (a 40-character hexadecimal string like a94a8fe5ccb19ba61c4c0873d391e987982fbbd3), and stores the content in a file named after that hash. This is called a blob object. The hash is both the name and the address of the content.

This design has an important consequence: if two files have identical content, they produce the same hash and Git stores the content only once. It also means that any change to content, however small, produces a completely different hash and a completely different object. Git never modifies stored objects — it only ever adds new ones. The store is append-only and immutable.

You can see this directly. In any Git repository, the .git/objects/ directory is where all these objects live, organized into subdirectories by the first two characters of their hash:

.git/objects/
  a9/
    4a8fe5ccb19ba61c4c0873d391e987982fbbd3
  3b/
    18e512dba79e4c8300dd08aeb37f8e728b8dad
Enter fullscreen mode Exit fullscreen mode

Git builds everything — files, directories, commits, tags — out of four types of these objects.

The Four Object Types

1. Blob

A blob stores the raw content of a file. Nothing else — no filename, no permissions, just bytes. Two files in different directories with the same content are represented by the same blob.

2. Tree

A tree is the Git equivalent of a directory. It's a list of entries, where each entry records a filename, file permissions, and a pointer (hash) to either a blob (for files) or another tree (for subdirectories).

A tree for a simple project might look like:

100644 blob a94a8fe5...  README.md
100644 blob 3b18e512...  main.go
040000 tree d8e7b0...    handlers/
Enter fullscreen mode Exit fullscreen mode

Because trees point to blobs by hash, and blobs are immutable, a tree captures a complete, exact snapshot of a directory at a given moment.

3. Commit

A commit is what ties everything together. A commit object contains:

  • A pointer to the root tree — the tree representing the entire project at this point in time
  • A pointer to the parent commit (or two parents, in the case of a merge commit)
  • Author and committer information — name, email, and timestamp
  • The commit message

Critically, a commit doesn't store a diff. It stores a complete snapshot of the entire repository — every file, every directory — represented as a tree of trees and blobs. When you look at a "diff" in git show or git log -p, Git is computing that diff on the fly by comparing the commit's tree to its parent's tree. The underlying storage is snapshots, not deltas.

4. Tag

A tag object stores a named reference to a specific commit (an annotated tag), along with a message and the tagger's identity. Lightweight tags are simpler — they're just references, not full objects — but annotated tags are first-class objects in the store.

What a Branch Actually Is

This is where most people's mental model of Git breaks down: a branch is not a container for commits. A branch is just a file containing a single hash — the hash of the commit that branch currently points to.

In your .git/refs/heads/ directory, there's a file for each local branch:

.git/refs/heads/main          → a3f5c21...
.git/refs/heads/feature/auth  → 9d2b441...
Enter fullscreen mode Exit fullscreen mode

That's it. Each file holds one commit hash. When you make a new commit on a branch, Git creates the commit object, then updates the file to hold the new commit's hash. The branch "moves forward" by updating a file — there's no restructuring of history, no moving of objects.

This means branches in Git are essentially free to create and incredibly cheap to work with. Creating a branch is creating a 41-byte file. Deleting a branch is deleting that file. No data is duplicated.

HEAD: Where You Are Right Now

There's a special file called .git/HEAD that records which branch (or commit) you're currently on:

ref: refs/heads/main
Enter fullscreen mode Exit fullscreen mode

When you run git checkout feature/auth, Git updates this file to point to refs/heads/feature/auth. When you run git commit, Git creates the new commit object, updates the file at refs/heads/feature/auth to point to the new commit, and HEAD continues to point at feature/auth.

When you check out a specific commit hash instead of a branch name (using git checkout a3f5c21), HEAD contains that hash directly instead of a branch reference. This is what Git calls a "detached HEAD" state — you're not on any branch, so new commits won't be tracked by any branch automatically. It sounds alarming but it's not dangerous as long as you understand what it means.

How Commits Form a Graph

Because every commit points to its parent commit(s), the full commit history forms a directed acyclic graph (DAG) — a graph where edges point in one direction and there are no cycles.

For a simple linear history it looks like:

A ← B ← C ← D   (main)
Enter fullscreen mode Exit fullscreen mode

Each arrow means "C's parent is B" — the arrow points backward in time toward the root. main points to D, the most recent commit.

When you create a branch and make commits on it:

A ← B ← C ← D         (main)
              ↑
              E ← F    (feature)
Enter fullscreen mode Exit fullscreen mode

feature points to F. main still points to D. Commits E and F have D as their parent — they branched off from D.

When you merge feature into main, Git creates a new merge commit with two parents:

A ← B ← C ← D ← G     (main)
              ↑   ↑
              E ← F    (feature)
Enter fullscreen mode Exit fullscreen mode

G is the merge commit. It has two parents: D and F. This is what makes the history a graph rather than a straight line.

How git rebase Works

Rebasing is one of the most misunderstood operations in Git, but the internal model makes it straightforward. When you run git rebase main from the feature branch, Git:

  1. Finds the common ancestor of feature and main (commit D in the example above).
  2. Takes each commit on feature that comes after that ancestor (E and F) and replays them, one at a time, on top of the current tip of main.
  3. Replaying means creating new commit objects with the same changes but different parent pointers (and therefore different hashes).
  4. Updates the feature branch reference to point to the last replayed commit.

The result looks like:

A ← B ← C ← D         (main)
               ↑
               E' ← F'  (feature)
Enter fullscreen mode Exit fullscreen mode

E' and F' are new commits — same content as E and F but different hashes because their parents changed. The original E and F objects still exist in the object store; they're just no longer reachable from any branch reference.

This is why rebasing "rewrites history" — it creates new commits — and why rebasing commits that have already been pushed to a shared branch is problematic. Anyone else who has those commits will have the old hashes, and when they try to reconcile with your rebased history, Git sees them as diverged.

The Staging Area (Index)

One piece of the model that trips people up is the staging area, also called the index. In most version control systems, you commit directly from the working directory. Git has an intermediate step.

The index lives at .git/index and represents the current staged state — what the next commit will look like. When you run git add file.go, you're not adding the file to a commit. You're updating the index to include the current version of file.go. When you run git commit, Git takes whatever is in the index and creates a tree from it, then creates the commit pointing to that tree.

This three-way distinction — working directory, index, committed history — is what gives Git its flexibility. You can stage part of a file's changes with git add -p, leaving other changes unstaged. You can stage changes across multiple files and commit them as a single logical unit. The index is the staging ground between "work in progress" and "permanent history."

Why Nothing Is Ever Really Lost

One practical consequence of the object store model: it's very hard to permanently lose work in Git, as long as you've committed it.

Even when you delete a branch, the commit objects that branch pointed to remain in the object store. They're simply no longer reachable from any reference. Git has a garbage collector that eventually cleans up unreachable objects, but it only runs automatically after a significant delay (typically 90 days by default).

The git reflog command is your recovery tool. It records every position HEAD has pointed to, even across operations like rebase, reset, or branch deletion. If you accidentally run git reset --hard and lose commits you wanted, git reflog will show you the hash of the commit you were on before the reset, and you can get back to it with git checkout or git reset.

git reflog
# a3f5c21 HEAD@{0}: reset: moving to HEAD~3
# 9d2b441 HEAD@{1}: commit: add authentication middleware
# ...

git reset --hard 9d2b441  # recover to before the reset
Enter fullscreen mode Exit fullscreen mode

This is why Git power users are rarely afraid of drastic operations — they know the objects are still there, and reflog is the map to find them.

Practical Things This Understanding Unlocks

Once the internal model clicks, a lot of everyday Git confusion resolves:

  • Merge conflicts stop being mysterious. They happen when two commits that share a common ancestor both changed the same part of the same file, and Git can't automatically decide which change to keep. You're resolving a difference between two timelines.
  • git reset makes sense. --soft moves the branch pointer but leaves the index and working directory alone. --mixed (the default) moves the pointer and resets the index but leaves the working directory. --hard moves everything. Three different operations, all clearly distinct.
  • Detached HEAD is not scary. You're just looking at a commit directly rather than through a branch. Make a branch before you commit anything important and you're fine.
  • Rebasing vs merging is a conscious choice. Merge preserves the exact history of what happened and when. Rebase produces a cleaner, linear history but rewrites commits. Neither is universally correct — the right choice depends on your team's conventions and what the history is meant to communicate.

Where to Go From Here

If you want to go deeper on any of this, one exercise is worth more than any amount of reading: build a toy Git from scratch. The core data model — blob, tree, commit, references — can be implemented in a few hundred lines. Writing git init, git add, and git commit from scratch, and seeing the objects appear in .git/objects, makes the mental model completely concrete in a way that reading about it never quite does.

The official Git documentation and the book Pro Git (free online at git-scm.com/book) cover everything here in more depth. The git cat-file command lets you inspect any object in the store directly (git cat-file -p <hash> prints the object's content in human-readable form), which is an excellent way to verify that what you've read here is really what Git is actually doing.

Every tool you'll use in your career as a software developer — CI/CD pipelines, code review platforms, deployment systems — is built on top of Git. Understanding it at the level of the model, not just the commands, is one of the highest-return investments you can make early in your career. The commands will always make more sense once you know what they're actually doing.

Top comments (0)