Git Working Tree Internals and Object Storage
Git stores complete snapshots as objects, not diffs.

Most developers think of Git as a diff tracker, a tool that records the changes between one version of a file and the next. That model is wrong, and it is wrong in a way that makes half of Git's behavior look mysterious when it is actually mechanical. Git is a content-addressable key-value store: hand it content, and it returns a SHA-1 hash; hand the hash back, and it returns the content. Git's object store addresses content by hash, not by diff. Everything Git tracks reduces to one of four object types, and understanding what each one actually is, rather than what it is used for, makes the rest of the system legible.
A blob holds raw file content and nothing else, no filename, no permissions, no path. A tree is a directory listing: each entry in a tree maps a filename and a permission mode to either a blob hash or the hash of a child tree, so the filename itself lives in the tree, never in the blob. A commit is a pointer to a root tree, bundled with an author, a committer, a timestamp, a commit message, and zero or more parent commit hashes. A tag is a named pointer, usually aimed at a commit, with an optional annotation attached, and it matters less for the arguments that follow than the other three.
The hash that identifies each object is derived entirely from its content. The object store does not store changes. It stores complete snapshots, addressed by what they contain.
How the object graph assembles from those four types
Once the four object types are in view, the next question is how they connect into something that represents a project's full history. Every commit points to exactly one root tree, and that root tree points outward to the blobs and child trees that make up the full directory structure at that moment. A tag, where one is present, points at a commit the same way a commit points at a tree: as a named reference into the same structure.
Picture the shape this produces: a commit at the tip of a branch points down into a tree, that tree points down into blobs and further trees for subdirectories, and the commit also points sideways, or rather backward, into its parent commit, which repeats the same structure one step earlier. This is a directed acyclic graph. Edges only point backward, from child commit to parent commit. Cycles are structurally impossible. A commit can never end up pointing, even indirectly, to one of its own descendants.
Merge commits are the one place where a commit holds two parent pointers instead of one, and that detail is the entire mechanism behind how Git represents divergence and reunion in a project's history. No special merge object exists anywhere in the four types. The graph does the representational work. Nothing about branching or merging requires Git to invent a fifth object type.
The index as the staging zone between working directory and object store
Developers routinely collapse three distinct zones into one mental blur, and the result is confusion about what a given Git command actually touches. The working directory is the files on disk, open in an editor, subject to change at any moment. The index is a record of what the next commit will look like if one is made right now. The object store is the immutable archive of everything already committed. Keeping these three separate is what makes the rest of Git's staging behavior make sense.
Running git add does two things at once, and both happen before any commit exists. The object is already sitting in the store at this point, fully formed, even though no commit has referenced it yet. Running git commit then reads the index, builds one tree object per directory represented there, writes those tree objects into the store, writes a commit object pointing at the resulting root tree, and advances the branch pointer. The working directory is not consulted again during this step. Everything git commit needs, it already has, sitting in the index.
That architecture is what makes partial staging possible. Running git add -p lets a developer stage a subset of the changes in a file, leaving the working directory, the index, and the last commit in three different states simultaneously. git diff with no arguments compares the working directory to the index. git diff --cached compares the index to the last commit. git diff HEAD compares the working directory directly to the last commit, bypassing the index. Each command is asking about a different pair of the three zones, and the index is the zone sitting in the middle of all three comparisons.
Branches and HEAD as refs into the object graph
With the object graph and the index in place, branches turn out to be far less substantial than they feel in daily use. A branch is not a copy of a project and it is not a container for a set of commits. It is a single file holding a commit hash, nothing more, and it advances by being rewritten to point at a new hash each time a commit is made on it. HEAD sits one layer above that. HEAD is normally a reference to a branch reference, not a direct pointer to a commit. The file at .git/HEAD typically contains something like ref: refs/heads/main, and the commit that HEAD resolves to changes automatically every time the branch it names advances.
That pointer-to-a-pointer structure is what makes detached HEAD state look alarming and be nothing of the sort. Detached HEAD simply means HEAD is pointing directly at a commit hash instead of at a branch name, skipping the middle step it normally takes. Nothing is broken. HEAD is doing what it always does, resolving to a commit, only now it is doing so in one hop instead of two.
The directory structure under .git/refs/ reinforces the same point. Local branches live as files under .git/refs/heads/, and remote-tracking branches live as files under .git/refs/remotes/. Both are nothing more than files holding commit hashes. That detail becomes directly relevant later, because it explains how a Git worktree can check out a separate branch into a separate working directory without duplicating a single object in the store: the worktree just needs its own ref file and its own index, not its own copy of the object graph.
Rebase, Cherry-Pick, and Merge in the Object Graph
Rebase, cherry-pick, and merge are usually taught as three competing workflows, each with its own etiquette and its own camp of advocates. Viewed through the object model, they are three different answers to a single mechanical question: how does this work get attached to a different point in the graph? None of the three requires a new object type, and none of them behaves unpredictably once the graph is visible.
Merge is the most conservative of the three at the object level. It adds exactly one new commit, carrying two parent pointers instead of one, and it moves the branch pointer forward to that new commit. The commits that existed on each branch before the merge are untouched, still sitting in the object store with the same hashes they had before, referenced now by a new commit that has absorbed both of them as parents.
Rebase and cherry-pick behave differently because they do not create a second parent pointer to reconcile two histories. Instead, they create new commits that replay the content of existing commits onto a different parent, producing new commit objects with new hashes even when the tree content they introduce is identical to the original. The graph addresses are different, and Git has no way to know the two commits are "the same" in any sense beyond content.
None of the three operations is permanently destructive, because the reflog and the object store work together as a safety net. Within that window, before garbage collection runs, every rebase and every cherry-pick is reversible by walking the reflog back to the prior state.
Packfiles and GC preserve the snapshot model while keeping disk use tractable
The object model as described so far treats every object as its own file, written individually to .git/objects/, zlib-compressed on disk. A repository with a long history and many contributors can end up with thousands of these loose object files, and operations that need to scan the object store slow down accordingly.
Packfiles exist to solve that read problem without touching the logical model that still governs how objects are addressed. From the perspective of any Git command, every object is still addressed by its content hash exactly as before. Packing related objects together into a single compressed file, and storing some objects as deltas against similar objects rather than as full copies, is a storage optimization layered beneath the object model, not a change to it. Commits, trees, and blobs relate to each other the same way once they live inside a packfile as they did as loose files.
Garbage collection does two jobs at once: it repacks loose objects into packfiles, and it prunes objects that have become unreachable, no branch, tag, or other ref points to them anymore. A commit left behind after a detached HEAD session, never captured by a branch or tag, is exactly this kind of orphan. It sits in the object store, eligible for collection, until a grace period passes, and that grace period is the same window in which the reflog can still recover it.
This matters operationally for any team or system running many automated Git sessions. Agent branches that are created, committed to, and never merged continue accumulating loose objects in the store until a garbage collection cycle runs and reclaims what is no longer reachable. On a repository fielding a high volume of automated commits, that accumulation can look like unexplained repo bloat if the GC cycle and its timing are understood as part of the normal object lifecycle.
What worktrees are at the object level
Git worktrees exist because the object store and the working directory were already architecturally separate, long before worktrees were introduced as a feature. A worktree is not a workaround bolted onto Git's design. It follows directly from a structural fact already established: the object store in .git/objects/ is a single shared resource, and nothing about it requires there to be only one working directory checking it out at a time. Multiple working directories can check out different branches from that same shared object store simultaneously, each with its own index and its own position in the ref graph.
Each worktree gets its own working directory on disk, its own branch checked out, and its own index file tracking what its next commit would look like. What it does not get is its own object store and its own full .git/ directory. A secondary worktree holds a .git file that simply points back to the main repository's .git/ directory, where the shared objects, branches, and tags all still live. Only a ref, an index, and a working directory are added.
That architecture is what has made worktrees the mechanism of choice for running multiple AI coding agents in parallel against the same repository. Claude Code's native worktree support lets an orchestrator spawn subagents configured with an isolation: worktree field in their YAML definitions, and each subagent spawned this way gets its own branch and its own working directory, cleaned up automatically once the subagent finishes provided it has no uncommitted changes left behind. Mike McQuaid's Sandvault and Superset, from 2026, split the same problem differently: Sandvault creates the sandbox user and a shared path, Superset creates the worktree under that shared path and launches the agent inside it, and a normal user can open that same worktree directly without maintaining a separate checkout on the host to keep synchronized.
Where worktree isolation stops for running agents safely
Worktree isolation operates at the filesystem level only, and that boundary is precise rather than approximate: it separates working directories, branch checkouts, and index files, and nothing more. It says nothing about the runtime environment an agent executes inside, the network it can reach, the processes it can spawn, or the credentials available to it while it works. Two agents can hold two completely isolated worktrees, each with its own clean branch and its own uncontaminated index, and still collide badly if both are running inside the same shared environment: the same installed dependencies, the same open network ports, the same database connection, the same background services.
This is the boundary that matters most for running agents in production. A worktree guarantees that one agent's uncommitted file changes cannot bleed into another agent's working directory, and that each agent's commits land on its own branch without interference at the object level. It guarantees nothing about whether two agents attempting to bind the same port, write to the same test database, or exhaust the same memory on a shared host will interfere with each other's work. The tools built around worktrees, Claude Code's isolation field, Container Use's pairing of worktrees with containers, and the Sandvault and Superset split between sandbox user and shared path, each represent a different answer to that gap. Filesystem isolation through worktrees is the layer Git provides natively. Whatever isolation an agent needs beyond the filesysture has to be supplied by whatever system is running it.


