Git Internals: Objects, Trees, and the DAG Under the Hood

Most developers use Git every day without ever looking beneath git commit and git push. Under the hood, though, Git is not a patch-management tool at all. As the Pro Git book puts it, Git is “fundamentally a content-addressable filesystem with a VCS user interface written on top of it.” Once you understand the four object types, the way they are hashed, and how commits link together into a directed acyclic graph, Git stops being mysterious and starts being predictable.

The four object types

Everything Git stores is an object, and there are exactly four kinds: blob, tree, commit, and tag. Every other Git concept — branches, merges, rebases — is built on top of these.

Blobs

A blob (Binary Large Object) stores the raw bytes of one file's contents, and nothing else. No filename, no path, no permissions, no metadata. When you run git add README.md, Git hashes the file's content and writes one blob; the filename and file mode live in tree objects instead. If two files in different directories contain exactly the same content, only one blob exists — identical content always means an identical object.

Trees

A tree records a directory listing. Each entry contains a file mode, an object type (blob or tree), the object's hash, and the filename. Trees point to blobs for files and to other trees for subdirectories, so one top-level tree describes the complete state of a project. Because tree entries reference their contents by hash, an unchanged subtree keeps the same hash across commits.

Commits

A commit is the unit of history. It points to exactly one top-level tree — a full snapshot of the project at that moment — and carries the author's name and email, the committer's name and email, two timestamps, the parent commit or commits, and the commit message. Note the plural: an ordinary commit has one parent, the repository's root commit has none, and a merge commit has two or more. That parent list is the edge list that turns history into a graph.

Tag objects

An annotated tag is an object: it records the tagger's name, a date, a message, optionally a GPG signature, and a pointer to another object — usually a commit. A lightweight tag is not an object at all; it is simply a ref aimed directly at a commit.

Content-addressable storage: how the hash works

Every object's identity is the cryptographic hash of its serialized form. Git builds a small header — the object type, a space, the content size in bytes, and a null byte — prepends it to the content, and hashes the whole buffer. In standard Git this uses SHA-1, producing a 40-character hexadecimal string. The hash is both the object's name and its integrity check: change a single byte of content and you get a different object entirely.

You can watch this happen with Git's plumbing commands:

$ echo "hello world" | git hash-object -w --stdin3b18e512dba79e4c8300dd08aeb37f8e728b8dad

The -w flag writes the blob into the object database. Git hashed the buffer "blob 12\0hello world\n" — the header plus the content — and that hash will be identical on any machine running the same calculation. Notice what is missing from the hash: the filename. Rename the file and the hash does not change.

The storage layout follows directly from the hash. The first two hex characters become a directory name under .git/objects/, and the remaining 38 characters become the filename; the file itself is zlib-compressed. This fan-out keeps any single directory from accumulating thousands of entries, which some filesystems handle poorly.

The .git directory: where objects live

Running git init creates a .git directory that contains nearly everything Git knows about your project — copying that single directory elsewhere gives you almost a complete backup. The important entries are:

  • objects/ — the object database: all blobs, trees, commits, and tag objects.
  • refs/ — human-readable pointers to objects: refs/heads/ for local branches, refs/tags/ for tags, refs/remotes/ for remote-tracking branches.
  • HEAD — a small file identifying what you currently have checked out, usually a symbolic reference like ref: refs/heads/main.
  • index — the staging area: a binary file that caches which blobs will make up the next commit's tree.
  • config — repository-specific settings such as remotes and user information.
  • hooks/ — client- and server-side hook scripts.
  • info/ — a global exclude file for patterns you never want tracked.

Over time Git may also create packed-refs, a single file consolidating many refs.

The commit graph is a directed acyclic graph

Every commit's parent fields are the edges of a graph whose nodes are commits. Because each commit points backwards to its parents, and a parent must exist before its child is created, the graph can never loop back on itself — hence directed acyclic graph, or DAG.

A merge commit simply has two parents, with edges to both branches being joined. Finding a merge base is a graph reachability problem; git log --graph draws the DAG; git rev-list walks it. Nearly every Git operation maps to a graph operation: creating a branch adds a movable label to one node, and rebasing replays a chain of nodes onto a new parent, creating new objects with new hashes.

One corollary of the DAG model: history is immutable. A commit's hash covers its parents' hashes, so altering any commit would cascade new hashes through every descendant. That is why rewriting history requires explicit force-pushing — the old nodes still exist; the branch label simply points somewhere new.

Refs and HEAD: human-friendly pointers

Nobody wants to type 40-character hashes, so Git provides refs: names that resolve to object hashes. A branch is a ref — literally a file containing one commit hash:

$ cat .git/refs/heads/maina689b02d158ae9eb63740f7ab220d6254b0193e8

Forty hex characters plus a newline: 41 bytes. That is the entire branch. Creating a branch writes one tiny file, which is why branching in Git is instant regardless of repository size. HEAD is the pointer to the pointer: it names the branch you are currently on, and commands like git commit move that branch ref to the new commit.

Packfiles and delta compression

Loose objects — one zlib-compressed file per object — are fast to write but wasteful at scale. When a repository grows, Git periodically repacks objects into .git/objects/pack/*.pack files (git gc triggers this). Packfiles use delta compression: objects with similar content are stored as deltas against a base object rather than as full copies, which is why long histories stay surprisingly small. You can inspect a packfile with:

$ git verify-pack -v .git/objects/pack/*.idx | head -20

It lists each object, its type, and whether it is stored whole or as a delta, including the base object each delta is built on.

Plumbing commands you can run today

These low-level commands expose the internals directly. Try them in any repository:

  • git cat-file -t <hash> — print the object type (blob, tree, commit, or tag).
  • git cat-file -p <hash> — pretty-print the object's contents.
  • git cat-file -s <hash> — print the object's size in bytes.
  • git hash-object -w <file> — hash a file and write it as a blob, without staging it.
  • git ls-tree HEAD — list the tree object behind the current commit.
  • git write-tree — build a tree object from the current index and print its hash.
  • git rev-list --all --count — walk the DAG and count every reachable commit.
  • git rev-list --max-parents=0 HEAD — find the root commit(s): the DAG's starting nodes.

Tip: you only need to type enough of a hash to be unique — git cat-file -p 3b18e5 works when no other object shares that prefix. Try this chain on a scratch repo: git hash-object -w a file, git cat-file -p the result, then git ls-tree a tree to see your blob referenced by name. The porcelain you use daily is just scripted combinations of these primitives.

SHA-1, SHA-256, and the hash transition

Git has always hashed objects with SHA-1, and SHA-1's practical collision attack (SHAttered, demonstrated in 2017) is why the project has a migration plan. Git already ships defenses against the known SHA-1 attacks, and for the moment every repository you clone still uses 40-character SHA-1 hashes.

SHA-256 support is built in and opt-in: git init --object-format=sha256 creates a repository whose object IDs are 64 hex characters instead of 40. Non-experimental SHA-256 support has existed since Git 2.42 (2023), but SHA-1 remains the default in current releases — a repository is entirely one format or the other, and submodules must match their parent's format. The planned flip to SHA-256 as the default is tied to the Git 3.0 release, which has no announced date, and there is no plan to deprecate the SHA-1 object format. The main blocker has been hosting support: GitLab enabled experimental SHA-256 repositories in version 17.3 (August 2024); GitHub's timeline remains unclear. Check a repository's format with git rev-parse --show-object-format.

For day-to-day work, nothing changes yet: keep using SHA-1 repositories, and treat SHA-256 as something to test in a scratch repository rather than production.

Further reading

Leave a Comment