How jj is actually built underneath

Part 4 of 9.

The mental model from the last post (working copy as a commit, change id separate from commit id) is the user-facing story. Reading through jj’s own design docs and source layout for a bit longer than strictly necessary, the underlying architecture explains why that model holds together, and why colocated mode doesn’t feel like running two version control systems glued together.

The commit store is a pluggable backend, and Git is one implementation of it. jj defines its own internal notion of a commit, and a backend trait decides how that gets turned into bytes on disk. The Git backend is the default and the one this whole course uses; it happens to store jj’s commits as real Git commit objects. That’s the actual mechanism behind “everything is still a normal Git repo underneath” - it isn’t a translation layer bolted on after the fact, it’s the primary storage path.

Colocated mode doesn’t duplicate object storage. .jj holds jj-specific state - the operation log, the current view, working-copy tracking data - but the commits, trees, and blobs themselves live in .git/objects, the same objects Git itself reads. That’s why deleting .jj leaves a fully intact, ordinary Git repository behind: there was never a second copy of the actual content to reconcile.

Change ids ride along inside the Git commit, invisibly to Git. Git has no native concept of a stable identity that survives a rewrite - a new commit is just a new object with a new hash, full stop. jj gets its change id by stashing an extra opaque identifier inside the underlying Git commit object, in a place ordinary Git tooling ignores. Git sees a normal commit; jj additionally sees the identity that ties it to the change’s history across edits and rebases.

The operation log is a DAG, not a linear history, and that’s deliberate. Every jj command produces a new operation, and each operation records a full snapshot of repo state (every bookmark, every head) plus a link back to the operation(s) it followed. Two terminals running jj against the same repo at the same time produce two operations with the same parent - a fork, structurally identical to two commits sharing a parent. The next jj command to look at the repo merges those forked operations automatically, the same three-way merge machinery used for merging file content, just applied to “what does the repo look like.” That’s a genuinely different answer to concurrent access than Git’s file-locking around individual ref updates, and it’s what makes running jj from two shells against one repo a non-event instead of a race condition.

Nothing gets garbage collected just for being unreferenced by a bookmark. Because liveness is tracked through the operation log rather than through named refs the way Git’s own GC works, a commit that isn’t attached to any bookmark - an experiment from lesson 5’s “new work on a past item” - stays fully intact and visible to jj log until it’s genuinely abandoned or the operation history referencing it ages out. Anonymous heads are a first-class, permanent thing to have lying around, not a GC race waiting to happen.

None of this changes anything about the commands from the other lessons. It’s the part that made me trust the tool rather than just enjoy its interface - the safety properties (concurrent-access correctness, undo covering everything, colocated mode being genuinely lossless) come from decisions at this layer, not from careful discipline layered on top of a fragile core.