How Git Works Internally: Objects, References, and the DAG Behind Every Commit
A deep dive into Git's content-addressable object store, how branches are just files, how the DAG enables branching and merging, three-way merge and rebase mechanics, packfile delta compression, and what all of this means for reflog, garbage collection, and recovering lost work.
Every developer runs git commit dozens of times a day, but most treat Git as a black box: stage, commit, push, done. That works until it does not. A botched rebase, a force-push gone wrong, a repository that somehow gained gigabytes overnight, a colleague who swears a commit “just disappeared.” At that point, intuition fails and you need a mental model of what Git actually stores and how it stores it.
Git is not a complicated system once you understand the three primitives it is built on: a content-addressable object store, a set of named references pointing into that store, and a directed acyclic graph (DAG) connecting commit objects through time. Everything else, including branches, merges, rebases, and packfiles, emerges from those three ideas.
The Object Store: Content-Addressable Storage
Git stores everything in .git/objects/. Every object is identified by the SHA-1 hash of its content. This is the definition of content-addressable storage: the address is derived from the data, not assigned independently. Two files with identical bytes produce the same hash and therefore the same object. Git has four object types.
Blobs
A blob stores raw file content. Nothing else. No filename, no permissions, just bytes.
# Write a blob directly and get its hash
echo "hello git" | git hash-object --stdin -w
# 8ab686eafeb1f44702738c8b0f24f2567c36da6d
# Inspect it
git cat-file -t 8ab686ea
# blob
git cat-file -p 8ab686ea
# hello git
The hash changes if the content changes. Two files in different directories with the same content share one blob object. That is deduplication for free.
Trees
A tree represents a directory. It maps names and modes to blobs (files) or other trees (subdirectories).
# Look at the tree for the current commit's root
git cat-file -p HEAD^{tree}
# 100644 blob a8c9f6e3... README.md
# 040000 tree 3f1e9d2a... src
# 100644 blob d29b2c5f... package.json
# Recurse into a subdirectory tree
git ls-tree -r HEAD src/
# 100644 blob 7c4e8a1d... src/index.ts
# 100644 blob 9b2f3c6e... src/utils.ts
A tree object captures a snapshot of a directory at a point in time. It contains no history.
Commits
A commit wraps a tree with metadata: author, committer, timestamp, message, and one or more parent commit hashes.
git cat-file -p HEAD
# tree 3a1b4c7d9e2f5a8b1c4d7e0f3a6b9c2d5e8f1a4b
# parent 7f2e5a8b1c4d7e0f3a6b9c2d5e8f1a4b7c0d3e6
# author Jane Smith <jane@example.com> 1747267200 -0500
# committer Jane Smith <jane@example.com> 1747267200 -0500
#
# feat: add request batching to the API client
The parent field is what creates the DAG. A merge commit has two parent hashes. An initial commit has none.
Tags
An annotated tag is its own object type. It wraps a target object (usually a commit) with a tagger, date, and message, and can be cryptographically signed.
git cat-file -p v2.1.0
# object 9a3b5c7d2e1f4a6b8c0d2e4f6a8b0c2d4e6f8a0b
# type commit
# tag v2.1.0
# tagger Jane Smith <jane@example.com> 1747267200 -0500
#
# Release 2.1.0: request batching and retry backoff
Lightweight tags are different: they are just references (discussed below), not objects.
References: Names Pointing at Hashes
Git’s references are remarkably simple. They are files that contain a SHA-1 hash.
cat .git/refs/heads/main
# 9a3b5c7d2e1f4a6b8c0d2e4f6a8b0c2d4e6f8a0b
cat .git/refs/heads/feature/auth
# 4d7e0f3a6b9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e
cat .git/HEAD
# ref: refs/heads/main
A branch is literally a file in .git/refs/heads/ containing the hash of the commit it points to. When you create a branch, Git writes a new file. When you commit on a branch, Git updates that file to the new commit hash. When you delete a branch, Git deletes the file. The commits themselves are untouched.
HEAD is a special reference that names the currently checked-out branch (or, in detached HEAD state, a commit hash directly). Remote-tracking references live in .git/refs/remotes/. Packed references are batched into .git/packed-refs for efficiency in repositories with many refs.
# Manually inspect all refs
git for-each-ref --format='%(refname) %(objectname:short)' refs/
# refs/heads/main 9a3b5c7
# refs/heads/feature/auth 4d7e0f3
# refs/remotes/origin/main 9a3b5c7
# refs/tags/v2.1.0 3f1e9d2
Tags and branches are both references. The difference is behavioral: Git moves a branch reference automatically on new commits; it never moves a tag reference.
The DAG: History as a Graph
Connect commit objects through their parent pointers and you get a directed acyclic graph. Directed because each edge goes from child commit to parent commit. Acyclic because a commit cannot be its own ancestor.
A --- B --- C --- D (main)
\
E --- F (feature/auth)
Here E and F are reachable from the feature/auth branch reference, and A, B are shared ancestors. The DAG is what makes branching cheap: creating a branch costs one file write. Checking out a branch costs a tree traversal to update the working directory. No copying.
Reachability
Git thinks in terms of reachability. An object is reachable if you can get to it by following references and parent pointers. Unreachable objects are candidates for garbage collection. This definition drives nearly every Git operation.
# Show all commits reachable from main but not from feature/auth
git log main..feature/auth
# Show commits reachable from either, excluding common ancestors
git log main...feature/auth --left-right
Branching and Merging
Fast-Forward Merge
When the target branch is a direct ancestor of the source branch, no merge commit is needed. Git simply moves the branch pointer forward.
Before: After (fast-forward):
A--B--C main A--B--C--D--E main
\ feature
D--E feature
git checkout main
git merge feature/auth
# Fast-forward
# main pointer moved to E, no merge commit created
Three-Way Merge
When the two branches have diverged, Git needs a three-way merge. It finds the merge base (the common ancestor), then computes diffs from that base to each tip and combines them.
A--B--C--G main (G is merge commit)
\ /
D--E--F feature/auth
The algorithm works file by file:
- Find merge base commit M (the lowest common ancestor in the DAG).
- Compute
diff(M, main tip)anddiff(M, feature tip). - For each hunk, if only one side changed, take that change. If both sides changed the same lines differently, report a conflict.
git merge feature/auth
# Auto-merging src/api/client.ts
# Merge made by the 'ort' strategy.
Git’s default merge strategy since 2.33 is ORT (Ostensibly Recursive’s Twin), a rewrite of the recursive strategy that handles corner cases more reliably and supports concurrent merging.
Rebase: Replaying Commits
Rebase reconstructs commits rather than joining branches. For each commit in the source branch (not in the target), Git computes the diff that commit introduced and applies it on top of the target.
git checkout feature/auth
git rebase main
Internally:
- Find the merge base of
feature/authandmain. - Extract the patch introduced by each commit between the merge base and
feature/authtip. - Apply each patch in sequence onto
main’s tip. - Create new commit objects with new hashes (same diffs, new parents, new timestamps if changed).
- Move the
feature/authreference to the last new commit.
The original commits (D, E, F) still exist in the object store. The branch reference now points to the new copies (D', E', F'). This is why rebasing rewrites history: same logical changes, different SHA-1 hashes.
Before rebase: After rebase:
A--B--C main A--B--C main
\ \
D--E--F D'--E'--F' feature/auth
Merge vs Rebase vs Squash: The Tradeoffs
| Strategy | History shape | Commit identity | Conflict surface | When to prefer |
|---|---|---|---|---|
| Merge | Non-linear, explicit merge commits | Preserved (original SHA-1) | Once at merge time | Long-lived feature branches, open-source PRs, audit trails |
| Rebase | Linear, no merge commits | Rewritten (new SHA-1) | Once per replayed commit | Short-lived branches, before opening a PR, local cleanup |
| Squash merge | Linear, one commit per PR | New single commit | Once at squash time | Small features, changelog hygiene, noisy commit history |
| Rebase + squash | Linear, clean logical units | Rewritten | Once per replay, then squashed | Large features split into logical sub-units before merge |
| Fast-forward only | Linear, strictest | Preserved | N/A (branch must be up-to-date) | Enforcing linear history at the repository level |
The key practical constraint: never rebase commits that other developers have already based work on. Because rebase creates new commit objects, any branch built on top of the originals will diverge from the rebased history and require a reconciliation.
Packfiles and Delta Compression
The object store starts as loose objects: one file per object. This is fast to write but expensive to store and transfer. Packfiles solve both problems.
When you run git gc (or when Git triggers it automatically), Git:
- Identifies all reachable objects.
- Groups similar blobs and computes binary deltas between them.
- Writes all objects into a single
.packfile with a.idxindex for random access.
# Trigger packing manually
git gc
ls .git/objects/pack/
# pack-9a3b5c7d2e1f4a6b8c0d2e4f6a8b0c2d4e6f8a0b.idx
# pack-9a3b5c7d2e1f4a6b8c0d2e4f6a8b0c2d4e6f8a0b.pack
# Inspect what is in a packfile
git verify-pack -v .git/objects/pack/*.pack | head -20
Delta compression is not symmetric: Git can delta-compress a newer version of a file against an older version, or vice versa. It chooses whichever produces the smaller delta. For text files like source code, deltas are typically a few hundred bytes regardless of file size because most lines do not change between commits.
# Count loose objects and packed objects
git count-objects -v
# count: 4 (loose objects)
# size: 16 (in KiB)
# in-pack: 18432 (objects in packfiles)
# packs: 1
# size-pack: 2048 (packfile size in KiB)
# prune-packable: 0
# garbage: 0
# size-garbage: 0
A repository with years of history and thousands of files typically compresses to a fraction of the total logical size of all versioned content, because deltas between versions of the same file are small.
Reflog: The Safety Net Below the Safety Net
Every time a reference moves (commit, checkout, merge, rebase, reset), Git appends an entry to the reflog at .git/logs/. This is separate from the commit DAG. It records what each reference pointed to over time, even if those commits are no longer reachable from any branch.
git reflog
# 9a3b5c7 HEAD@{0}: rebase finished: returning to refs/heads/feature/auth
# 4d7e0f3 HEAD@{1}: rebase: feat: add token refresh
# 7f2e5a8 HEAD@{2}: rebase: feat: session persistence
# 3a1b4c7 HEAD@{3}: checkout: moving from main to feature/auth
# 9a3b5c7 HEAD@{4}: commit: fix: handle 429 responses
# Show reflog for a specific branch
git reflog refs/heads/main
# Recover a "lost" commit by its reflog entry
git checkout HEAD@{5}
# or
git branch recovery/lost-work HEAD@{5}
After a git reset --hard or a botched rebase, the commits are not deleted. They are unreachable from any reference, but they exist in .git/objects/ until garbage collection runs. The reflog keeps those entries for 90 days by default (gc.reflogExpire), giving you a recovery window.
Garbage Collection and Object Lifecycle
Git’s garbage collector removes unreachable objects that have aged out of the reflog. The lifecycle:
- Object is created (loose object written to
.git/objects/). - Object becomes unreachable (branch deleted, reset performed, rebase completed).
- Object remains in reflog for 90 days (
gc.reflogExpire) or 30 days if unreachable (gc.reflogExpireUnreachable). - After expiry,
git gc --prunedeletes the loose object or excludes it from the next packfile.
# See what gc would prune without actually pruning
git fsck --unreachable
# Prune objects older than 2 weeks
git gc --prune=2.weeks.ago
# Aggressive repacking (slower, better compression)
git gc --aggressive
For objects you want to keep indefinitely without a branch reference, create a ref or a tag. References are the anchor points. Without them, objects drift toward collection.
Production Considerations
Large binary files break delta compression. Git packfiles delta-compress text well but not binaries. A 50 MB compiled artifact committed once costs 50 MB; committed a hundred times costs nearly 5 GB. Use Git LFS or exclude binaries from version control entirely. Audit with git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | sort -k2 -rn | head -20 before a repository balloons.
Shallow clones reduce transfer cost at a tradeoff. git clone --depth=1 fetches only the latest commit tree, not full history. CI/CD pipelines that do not need history benefit from this. The tradeoff: operations that need ancestor information (bisect, blame, log) fail or fetch on demand.
Reachability packing matters in large monorepos. Repositories with thousands of refs (feature branches, tag-per-deploy, PR refs) incur overhead on reference iteration. Pack refs explicitly with git pack-refs --all and prune stale remote-tracking refs with git remote prune origin on a schedule.
The reflog is local only. A cloned repository has its own reflog, not the upstream’s. If a force-push removes commits from the remote and your local reflog has expired, recovery requires whoever had the commits locally. For critical branches, configure receive.denyNonFastForwards server-side and protect branches through the hosting layer.
Signing commits adds integrity at the cost of key management. git commit -S signs with GPG or SSH. Verified signatures prove authorship but require distributing public keys, managing revocation, and tooling support across the team. Adopt it for release tags first, where auditability matters most.
Bisect leverages the DAG. git bisect performs a binary search over the commit graph. Given a known-good and known-bad commit, it checks out the midpoint, accepts your “good” or “bad” verdict, and narrows by half each time. A 10,000-commit history requires roughly 14 verdicts to isolate a regression. This only works when commits are atomic and the test suite is fast. Noisy commit history (WIP saves, merge commits that mix changes) degrades bisect precision.
Force-push and shared history do not mix. Rebasing and force-pushing a shared branch creates divergent histories for all collaborators. Their local branch references still point to the original commits; the remote now points to the rebased commits. Every collaborator must git fetch and then hard-reset or re-checkout. Establish clear team norms: rebase is for local and pre-PR cleanup; shared branch history is append-only.
Closing
Git’s design reflects a particular philosophy: immutable content-addressed storage, with mutable references as the only mechanism for naming things. Once you see that commits are immutable objects and branches are just pointers, operations that seemed magical become mechanical. A rebase moves a pointer and creates new objects. A merge commit joins two DAG paths. A reset moves a pointer backward; the old commits persist until GC prunes them.
The plumbing commands (cat-file, hash-object, ls-tree, for-each-ref, rev-list) are not obscure trivia. They are the interface to the underlying data model. When something goes wrong at the porcelain layer, the plumbing is where you go to understand what actually happened and how to fix it without guessing.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.