How Git stores your code as snapshots, not diffs.
Git is a content-addressed key-value store with three main object types and a directed acyclic graph of commits on top. Branches are pointers and merges are commits with two parents.
The three objects Git is built from: blob, tree, commit
Git is a content-addressable filesystem with a version-control UI bolted on top. Linus Torvalds wrote it in April 2005 in a few weeks after BitKeeper revoked the kernel's free licence. Internally, Git is four object types (blob, tree, commit, tag) all addressed by SHA-1 (transitioning to SHA-256). Every clone is a full copy of the repository.
Git stores three main kinds of object. A blob is a file's bytes. A tree is a directory listing: names, modes, and pointers to blobs or sub-trees. A commit wraps a tree with metadata: who, when, why, parents. Every object is keyed by the SHA-1 of its content; two objects with identical bytes resolve to the same hash, no matter who created them or when. It works as a content-addressed hash table on disk.
Click between the three to see one of each.
tree a1b2c3d parent f12abe7 author Alice <alice@x> 1730000000 +0000 committer Alice <alice@x> 1730000000 +0000 Add hello.js
A commit pins a tree to a moment, with one or more parents. Merge commits have two. Initial commits have none. The commit hash covers the tree, the parent list, the author/committer, and the message: changing any of them changes the hash.
Git history is a graph of commits, not a straight line
Each commit points at its parent (or parents). Branches are not objects of their own. They are names that point to a single commit. A merge between two branches is a commit whose parent list has two entries; the working tree is the merged content.
Most commands follow from this shape. git log walks parent edges; git diff a..b diffs trees; git merge-base finds the lowest common ancestor in the DAG. Commit hashes work as immutable identifiers, much like distributed IDs do for a row. The DAG is the data structure, and most commands are queries on it.
# A small history with one merge A — B — C — D — E (main) \ \ F — G —— M (M is the merge of E and G)
A Git branch is just a file holding one commit hash
A branch is a file under .git/refs/heads/ containing one commit hash. For main that is 40 hex characters and nothing else. git branch new writes another file. git checkout updates .git/HEAD, which is itself a ref pointing at a branch ref. The branch stores no history or creation time, only the hash of the tip.
Tags are similar but live under .git/refs/tags/. Lightweight tags are just refs. Annotated tags are real tag objects (the fourth object type) with a message and a signer.
What the Git staging area is
The index (.git/index) is Git's staging area. It holds the proposed next tree, and is loosely comparable to a database write-ahead log for the working copy. git add writes blobs and updates the index. git commit snapshots the index into a tree, wraps it with metadata, and points the current branch at the new commit.
Saving a change goes through three stages: object, then index, then tree. Knowing this explains much of the unexpected behaviour of staging and committing. git diff diffs working tree vs index; git diff --cached diffs index vs HEAD's tree; git diff HEAD diffs working tree vs HEAD's tree.
How Git keeps the repo small with packfiles
Every commit stores a fresh blob for each changed file, which is fine for a small repo. For a large one, git gc packs many objects together as deltas (one full version + many "this differs from that by X bytes"). The packfile uses zlib for the bulk and binary diffs between similar blobs to drop redundancy.
This is why git clone is reasonably fast even on large repos: the server sends one packfile, not thousands of objects. It is also why the .git directory is far smaller than the sum of all historical file sizes.
Merge vs rebase: two ways to combine branches
Merge creates a new commit with two parents, recording that two histories converged, which is loosely like a database transaction. It records what happened, but a busy repo accumulates many merge commits.
Rebase rewrites your commits on top of theirs. The history looks linear, but the original commits are replaced with new ones (different hashes, same patches). The history is clean, but the pre-rebase state is lost unless you saved it.
Pick one per project and stick with it. Both are valid, and consistency matters more than the choice.
Preserves history
Keeps every commit. Records that A and B converged at point M. Easy to revert. The log is branchy. Good for shared branches.
Rewrites history
Replays your commits onto theirs. Hashes change and the original commits are orphaned. The log is linear. Use it for local work, not for shared branches.
Trunk-based, GitHub flow, git-flow: pick by release cadence
The DAG places no constraint on how you branch, so your release process decides. Every named strategy comes down to three decisions: how often you ship, what a bad change costs to undo, and who is allowed to merge.
- Trunk-based
- Everyone commits to main; branches live hours, not days; unfinished work hides behind feature flags. Cadence: continuous, every green commit is shippable. Revert cost: one small commit to back out. Who merges: nobody, really; CI is the gatekeeper. It needs fast tests, feature flags, and the discipline to keep half-built work hidden. Without those, trunk-based just means everyone breaks main together.
- GitHub flow
- Branch from main, open a pull request, review, merge, deploy. Cadence: per-PR, usually daily or better. Revert cost: revert the merge (or squash) commit, one operation, because each PR is one unit. Who merges: the author, after review. The default for most teams. It keeps main deployable and gives review a natural unit.
- git-flow
- Long-lived develop and main, plus release and hotfix branches. Built for scheduled, versioned releases: installed software, mobile release trains, anything with several supported versions in the field at once. Revert cost: the highest, a bad change may need cherry-picks across every active release branch. Who merges: a release manager, explicit or de facto. A poor default for a web service that deploys daily, and the right shape if you must patch v2.3 while building v3.
A common mistake is copying a strategy from a company with a different release process. A two-person team running git-flow does ceremony nobody needs, and a mobile team going "trunk-based" finds it still needs release branches the first time a store review takes a week. Start from how you ship.
On rebase versus merge, the rule "never rewrite shared history" covers most of what matters, and the rest is mostly taste. A team that rebases feature branches before merging gets a log that reads like a changelog, and a team that merges gets a log that records what happened. Both work. Force-pushing a branch a reviewer has already pulled, rebasing mid-review so the new diff can't be compared with the old one, and rewriting anything someone else's work sits on top of all cause problems. Agree on one convention and set the repo's merge-button policy to match (merge commit, squash or rebase, but only one).
Git is distributed: every clone is a full repository
Distribution is part of Git's core model. Your laptop holds the full DAG. push sends new objects to a remote that does not yet have them; fetch downloads what the remote has and you do not. Both operations are content-addressed: the wire format names objects by hash and skips ones the other side already has, which works like a cache negotiation.
Forks, mirrors, and the GitHub model all sit on top. Git itself has no central server, and "origin" is just one remote among possibly many. Having one canonical remote is a workflow choice.
How to recover lost commits with the reflog
Most "lost commits" are not lost. Git's reflog records every change to HEAD for ninety days by default. git reflog shows it; you can git checkout any of those hashes back. Even after a rebase that seems to have destroyed your branch, the original commits are still there, orphaned but reachable from the reflog until garbage collection runs.
Commits are only really lost when they are unreachable and old enough for git gc to prune them, which by default means two weeks.
Git at scale: what breaks in giant monorepos
Git was designed for the Linux kernel: a few hundred contributors, ~70k files, ~5GB of history in 2024. Beyond that scale the assumptions start to strain. Some large examples:
- Linux kernel
- ~70k files, ~1M commits since 2005. Plain git handles it, with a clone of roughly 3 GB. It is the workload Git was built for.
- Chromium
- ~340k files, ~1.5M commits, ~25 GB clone. Google ships depot_tools and partial-clone workflows because plain git struggles.
- Microsoft Windows · ~3.5M files
- The reason VFS for Git (now Scalar) exists. Files are virtualized, visible in the filesystem but only fetched when accessed, which made git status on the full 270 GB repo feasible.
- Google internal · piper
- Not Git. Google's monolithic repo holds about 2 billion lines of code and runs on an internal system called Piper. Potvin and Levenberg's paper "Why Google Stores Billions of Lines of Code in a Single Repository" (CACM 2016) explains why an off-the-shelf VCS did not fit.
- Meta · Mercurial
- Started on Git, moved to Mercurial around 2012 specifically because Hg's extensibility made the "shallow + virtual filesystem" pattern easier. Now uses a fork called Sapling (open-sourced 2022).
Modern Git scale features. Partial clone (git clone --filter=blob:none, since 2020) skips downloading file contents until needed. Sparse checkout limits the working tree to a subset. Commit-graph files speed up git log by orders of magnitude. Bundle URIs (since git 2.38) let CDNs serve the bulk of a clone. Together these turn 30-minute Chromium clones into 30-second ones.
How GitHub, GitLab, and Gitea store your repos
Every Git host is a Git server (the protocol) plus a web UI, auth, CI and issue tracking. The differences are operational.
GitHub (~100M users, 2024) runs custom servers on top of the Git protocol: their backend is called Spokes (replicated, multi-leader storage), behind a git-http-backend-style frontend. Issue tracking, Actions CI, Copilot, and Codespaces are layered on. GitHub's engineering blog documents the move from a single-leader DGit to multi-leader Spokes for resilience.
GitLab (~30M users) runs Gitaly as the storage daemon (Go service that wraps git for a shardable, gRPC-accessed backend) plus a Rails monolith for the UI. Self-hostable as the GitLab Community Edition; many large companies run on-prem GitLab. Storage is plain bare repositories on disk.
Gitea / Forgejo are lightweight self-hosted alternatives. Gitea (Go) is <100MB resident; Forgejo is a community-driven Gitea fork after Gitea's 2022 corporate transition. Both ship issues, pull requests and Actions-compatible CI.
Bitbucket (Atlassian) is used by many enterprises and is integrated with Jira and Confluence. Self-hosters use Bitbucket Data Center, and the cloud product has similar features to GitHub.
Git is a small system with an inconsistent command-line interface. Once the three objects, the DAG and the index are clear, the commands are much easier to reason about. Most complaints that Git is hard are really about the porcelain commands being inconsistent, while the plumbing is simple.
Further reading on Git internals
- Pro GitGit Internals: Plumbing & PorcelainThe chapter that explains Git's object model and the plumbing commands.
- Build it yourselfWrite Yourself a GitA tutorial that builds a small Git in about 500 lines of Python.
- Semicolony guideHash tablesContent-addressed storage is hash tables on disk. The algorithms are the same.
- Semicolony guideWrite-ahead loggingA different domain with the same idea: an append-only log gives you a queryable history.