How Git Works Under the Hood

Git is the most widely used version control system in the world, yet few developers understand its internals. A look at the elegant data structures beneath the commands.

More Than a Version Control System

Most developers interact with Git through a handful of commands: clone, add, commit, push, pull, merge. These commands feel like operations on files and branches, and that mental model works well enough for daily use. But beneath this interface lies a surprisingly elegant system built on a small number of core concepts. Understanding these internals does not just satisfy curiosity. It makes you better at using Git, especially when things go wrong.

At its core, Git is a content-addressable filesystem with a version control system built on top. That sounds abstract, so let us break it down piece by piece.

The Object Database

Everything Git stores lives in the directory. This is Git's object database, and it contains exactly four types of objects: blobs, trees, commits, and tags.

Blobs store file contents. When you add a file to Git, its contents are compressed and stored as a blob. The blob does not contain the filename or any metadata, just the raw content. Two files with identical contents, even in different directories with different names, share a single blob.

Trees represent directories. A tree object contains a list of entries, each pointing to either a blob (for files) or another tree (for subdirectories), along with the filename and file mode. A tree is essentially a snapshot of a directory at a particular point in time.

Commits are the heart of version control. A commit object contains a pointer to a tree (the snapshot of the project at that moment), zero or more pointers to parent commits, the author and committer information, and the commit message. The first commit in a repository has no parent. A normal commit has one parent. A merge commit has two or more parents.

Tags are annotated references to specific objects, usually commits. They include the tagger's name, a date, and a message.

Content Addressing

Every object in Git's database is identified by a SHA-1 hash of its contents (Git is transitioning to SHA-256, but SHA-1 remains the default). When you create a blob, Git computes the SHA-1 hash of the content prefixed with a header, and that hash becomes the object's name. The object is stored in in a subdirectory named after the first two characters of the hash, with the remaining characters as the filename.

This has profound implications. If two files have the same content, they have the same hash and are stored only once. If you modify a file, even by a single byte, the resulting blob has a completely different hash. The entire history of a Git repository is a directed acyclic graph of content-addressed objects, and the integrity of the entire graph is guaranteed by cryptographic hashing. If any object is corrupted or tampered with, its hash will not match, and Git will detect the corruption.

How a Commit Really Works

When you run , here is what actually happens:

  1. Git takes the current state of the staging area (the index) and creates tree objects representing the directory structure. Unchanged directories reuse existing tree objects.

  2. Git creates a commit object pointing to the root tree, the parent commit (the current HEAD), and your author/committer information and message.

  3. Git updates the current branch reference to point to the new commit.

That is it. A commit does not store diffs. It stores a complete snapshot of the project. This is counterintuitive, as most people think of commits as storing changes. But Git stores snapshots, and it computes diffs on the fly when you ask for them (with or ).

This snapshot-based model is why Git operations like switching branches or checking out old commits are fast: Git is not reconstructing a state by replaying diffs. It is simply reading the tree stored in the commit.

Branches Are Just Pointers

A branch in Git is nothing more than a file in containing a 40-character SHA-1 hash pointing to a commit. When you create a branch, Git creates a file. When you make a commit on that branch, Git updates the file to point to the new commit. That is why creating a branch in Git is nearly instantaneous, regardless of the repository's size.

HEAD is a special reference stored in that points to the current branch (or directly to a commit in "detached HEAD" state). When you switch branches, Git updates HEAD to point to the new branch and updates the working directory to match the commit that branch points to.

This simplicity is one of Git's most powerful design decisions. Branches are cheap. Merges create commits with multiple parents. The entire branching model emerges from these two simple rules.

The Index (Staging Area)

The index, stored in , is a binary file that represents the proposed next commit. When you run , you are updating the index to include the current contents of the specified files. When you run , Git creates tree objects from the index and wraps them in a commit.

The index serves as a buffer between the working directory and the repository. This three-state model (working directory, index, repository) is one of the things that makes Git flexible but also one of the things that confuses newcomers. Many version control systems have only two states: the working directory and the repository. Git's index gives you fine-grained control over what goes into each commit.

Packfiles and Compression

If Git stores a complete snapshot for every commit, does that not waste enormous amounts of space? In practice, no, for two reasons.

First, Git reuses objects. If a file has not changed between commits, the new tree points to the same blob as the old tree. Only changed files create new blobs.

Second, Git periodically packs objects into packfiles (). During packing, Git identifies objects with similar content and stores them as deltas: it stores one object in full and the others as differences from it. This is similar to how other version control systems work, but with a key distinction. Git chooses the delta base heuristically and can delta against any object, not just the previous version of the same file. This often results in better compression.

A fresh clone of a repository receives its objects in packfile format, which is why cloning is relatively efficient even for repositories with long histories.

Refs and Reflog

Beyond branches, Git uses refs (references) for tags, remote tracking branches, and other named pointers. All refs live under . Remote tracking branches live in . Tags live in .

The reflog, stored in , records every change to every ref. Every time HEAD changes, the reflog records the old and new values along with a timestamp and description. This is your safety net. If you accidentally reset to the wrong commit, force-push the wrong branch, or otherwise lose track of a commit, the reflog usually has a record of where it was. Reflog entries expire after 90 days by default, giving you a generous window to recover from mistakes.

Merge and Rebase: Two Approaches to Integration

When you merge two branches, Git finds their common ancestor (the merge base), computes the diffs from the merge base to each branch tip, and applies both sets of changes. If the changes do not conflict, Git creates a merge commit with two parents. If they do conflict, Git marks the conflicted regions and asks you to resolve them.

Rebase takes a different approach. Instead of creating a merge commit, it replays the commits from one branch on top of another, creating new commits with different parent pointers. The end result is a linear history, which some teams prefer for its cleanliness. The trade-off is that rebase rewrites history: the new commits have different hashes than the originals, which can cause problems if those commits have already been shared with others.

Both approaches are valid, and the choice between them is largely a matter of team preference and workflow.

The Reflog: Git's Safety Net

The reflog (reference log) records every change to HEAD and branch tips. When you reset, rebase, or amend a commit, the previous state is recorded in the reflog. This means that even destructive operations are recoverable: if you accidentally reset a branch, the lost commits still exist in the object database and can be found through the reflog.

The reflog is local to each repository and is not shared through push or pull. It expires entries after 90 days by default (30 days for unreachable commits). This makes it a personal safety net, not a collaboration tool. Understanding the reflog transforms Git from a tool where mistakes are permanent to one where almost anything can be undone.

The Index (Staging Area)

The index, also called the staging area or cache, is a binary file (.git/index) that records the state of the next commit. When you run git add, you are updating the index to include the latest version of a file. When you run git commit, Git creates a tree from the index and wraps it in a commit object.

The index enables partial commits: you can stage changes from some files while leaving others unstaged. This granularity is impossible in version control systems that commit the entire working directory.

The index also serves as a merge workspace. During a merge conflict, the index holds three versions of each conflicted file: the common ancestor (stage 1), the version from the current branch (stage 2), and the version from the merged branch (stage 3). Resolving the conflict means writing a version to stage 0 (the normal, non-conflicted stage).

Pack Files and Compression

Git stores objects individually as loose objects when they are first created. Over time, Git packs loose objects into pack files, which use delta compression to store objects efficiently. A pack file stores the most recent version of each object in full and stores older versions as deltas (differences) from newer versions.

This compression is remarkably effective. A repository with gigabytes of history may occupy only a few hundred megabytes on disk. The garbage collection process (git gc) repacks objects, prunes unreachable objects, and optimizes the repository's storage.

The pack file format also enables efficient network transfer. When you clone or fetch, Git negotiates with the remote to determine which objects you need, then creates a thin pack containing only those objects. This negotiation process is why the first clone of a large repository is slow, but subsequent fetches are fast: most objects are already local.

Distributed Git: Remotes and Protocols

Git s distributed nature means every clone is a fully functional repository with complete history. When you clone a repository, Git copies the entire object database and creates remote-tracking branches that mirror the remote s branch state.

The fetch operation downloads new objects and updates remote-tracking branches without modifying your local branches. The pull operation is fetch followed by merge (or rebase, depending on configuration). The push operation uploads local objects and updates the remote s branch references. Understanding this separation clarifies why pull can cause merge conflicts while fetch never does.

Git supports multiple transfer protocols, each with different trade-offs. The SSH protocol provides authentication and encryption through SSH keys. The HTTPS protocol provides authentication through username/password or tokens and works through corporate firewalls that block SSH. The Git protocol (git://) is unauthenticated and unencrypted, used only for public read-only access. Most modern workflows use SSH for developer access and HTTPS for CI/CD systems.

The negotiation protocol during fetch is sophisticated and efficient. Your Git client tells the server which objects it already has (by listing the SHA-1 hashes of its branch tips). The server computes the set of objects you need (reachable from the remote branches but not from your existing objects) and sends a pack file containing only those objects. For incremental fetches, this is extremely efficient: the negotiation overhead is proportional to the number of branch tips, not the size of the repository.

Hooks, Worktrees, and Submodules

Git hooks are scripts that execute at specific points in the Git workflow. Pre-commit hooks run before a commit is created, making them useful for linting, formatting, and running tests. Pre-push hooks run before a push, useful for preventing pushes to protected branches. Post-receive hooks run on the server after receiving a push, useful for triggering CI/CD pipelines, deploying code, or sending notifications. The Husky npm package and pre-commit Python framework have made hook management more accessible by allowing hooks to be version-controlled and shared across a team.

Worktrees allow multiple working directories to share a single repository. Instead of cloning a repository multiple times to work on different branches simultaneously, you can create additional worktrees linked to the same .git directory. Each worktree has its own working directory and index but shares the object database and reflog. This feature is particularly valuable for code reviewers who need to check out a colleague s branch without disrupting their own work in progress.

Submodules allow a Git repository to include other Git repositories as subdirectories. Each submodule is an independent repository with its own history, branches, and remote. The parent repository tracks a specific commit of each submodule, creating a reproducible dependency relationship. While submodules are powerful, they add complexity to the workflow: pulling, pushing, and branching must account for the submodule state. Git subtree offers a simpler alternative that merges the external repository s history directly into the parent, avoiding the two-repository synchronization overhead at the cost of a larger repository.

Understanding these internal mechanisms transforms Git from a set of memorized commands into a system with predictable, debuggable behavior. When something goes wrong, knowledge of objects, refs, the index, and the reflog provides the mental model needed to diagnose and fix the problem rather than searching for the right incantation online.

Key Takeaways

Why Understanding Internals Matters

When something goes wrong in Git, and it will, understanding the internals gives you the tools to fix it. A "detached HEAD" is not scary when you know HEAD is just a pointer. A failed rebase is recoverable when you know about the reflog. Merge conflicts are less mysterious when you understand the three-way merge algorithm.

Git's internals are also a masterclass in software design. A small number of simple, composable concepts (content-addressed objects, pointers, and a staging area) combine to produce a system of remarkable power and flexibility. Linus Torvalds designed Git in a matter of weeks in 2005, and the core data model has remained essentially unchanged since then. That is the mark of a good design.