Version Control
Cairn makes version control ambient. An agent edits files, runs builds and tests, and ships a pull request; underneath each step is a commit, a branch, a push, sometimes a merge or a rebase — and the system issues them. What stays with the agent is the part that needs judgment: the commit message, the pull-request body, the resolution of a real conflict.
What you see as a user:
- Each issue runs on its own branch, isolated from your checkout and from every other running issue.
- Changes are committed as they happen — every agent step lands as a commit, so there is never uncommitted work in flight.
- Pull requests and cleanup are system-managed: a finished agent's branch is published, its pull request opened, and its bookkeeping reclaimed once the issue closes.
The whole arrangement rests on two rules, and both describe Jujutsu (jj), which Cairn runs on top of git.
Rule 1: No agent owns a checkout
An agent job is a branch, not a directory. Each project has one shared jj store under the Cairn home, holding one commit graph and one operation log, and its backing object database is the project's own .git — so every commit an agent makes lands in the project's real object store, pushable to origin and visible to ordinary git tooling. A job holds a durable bookmark in that store plus a logical head commit, and that commit is the project content the agent sees.
one project, one shared commit graph, no agent checkouts:
/Users/you/projects/app/ ← your checkout; agents never write to it
~/.cairn/jj-stores/app/ ← the shared store: one graph, one op log,
backed by the project's own .git
├─ agent/CAIRN-42-builder-0 ← a branch, not a directory
├─ agent/CAIRN-42-review-0
└─ agent/CAIRN-43-builder-0
executor cells ← disposable checkouts materialized at an
exact commit, purely to run commands
So file: paths do not resolve against any directory on disk. A read is served from the store at the job's logical head; a write enters a store-locked transaction that advances it. The runner is the only writer of refs. The agent's process does have a directory, but it is scratch — somewhere for CLI-local state and temp files, reclaimed with the job — and it is never what file: addresses.
Commands are where real files become unavoidable — a compiler cannot read a commit graph. So a repository command runs in an executor cell: a checkout materialized at an exact commit, admitted either for one batch or for the lifetime of a long-running process such as a terminal or a dev server.
A cell is a checkout, but it is not the agent's, which is why the rule above still holds as stated. It owns no refs, advances nothing, and is thrown away; it is a projection of a coordinate, the way a printout is a projection of a document. When the batch finishes, the delta it produced is published back onto the branch as one validated unit — the branch is still what changed, and the runner is still what changed it.
What every job shares is the graph. A commit one agent seals is immediately a fact in the same graph every sibling reads from, which is what lets agents build on each other's work — while nothing they do can touch your checkout, or each other's files.
How a job gets its branch
A job's branch is decided when the job is created, one of three ways:
- Its own branch from the project's trunk. The default — a fresh branch cut from whatever the project's trunk actually is.
- A parent's branch, inherited. Some nodes continue the branch of the node that spawned them — a review or pull-request step on the builder's work, or a sub-agent task continuing its parent's. They start at that branch's live head rather than minting one of their own, and a task whose parent head cannot be resolved fails at spawn rather than quietly starting from the base.
- A parent issue's branch. When an issue has a parent issue, its branch is cut from the parent's latest branch and merges back into it, not into trunk. This is how a Coordinator splits a feature into child issues, builds them on its own integration branch, and integrates the whole feature before any of it reaches trunk.
The branch a job starts from is chosen once, at creation, and frozen.
Rule 2: There is no uncommitted work
Between any two tool calls, everything an agent has done is a commit. There is no intermediate state — no half-written file, no staged-but-uncommitted change — because the branch head is the content, and the only way content changes is by advancing it. In jj's model the working copy is itself an addressable commit at all times, which is why this is native behavior rather than something Cairn simulates.
What Cairn adds is a single gate at every tool call that could change files, the commit barrier:
- A file edit always carries a commit message. The edit and the commit are one act: the change lands as a commit, or it doesn't land.
- A shell command can change files on its own — a formatter, an install that rewrites a lockfile. If the command carried a commit message, those changes are sealed into a commit; if it didn't and the tree came back dirty, the change is discarded through jj's operation log and the agent retries with a message.
- A message of
^folds the change into the previous commit instead of making a new one.
agent step branch state after the step
────────────────────────────────────
edit src/a.rs → sealed commit "tighten validation"
run `cargo fmt` → sealed commit "fmt"
read src/b.rs → no change — clean
run a failing test → no change — clean
edit src/b.rs → sealed commit "fix off-by-one"
between every step: the branch head is the content
Because every step is a commit, any earlier state is still right there: "what did the agent change this turn?" is a diff between two commits, and a turn process that dies halfway leaves no half-written files, since the last good state is the latest commit.
Two guardrails ride on the barrier. First, publication is tree-based: what gets published is the content a batch produced, applied onto the branch head the store actually holds — never the batch's own commit objects. So a base that advanced mid-batch is absorbed rather than fought. A batch that straddles an advance is three-way merged onto the moved head using the batch's declared base as the merge base, and only a genuine textual conflict is handed back to be resolved in place and re-sealed.
Second, Cairn will not publish into a checkout it does not own. A checkout it provisioned carries a branch marker; one it merely found — your own colocated repository, say — does not, and there Cairn withholds every destructive or publishing action. It commits locally, moves no bookmark, and leaves stray changes alone with a warning rather than reverting them. Absence of ownership is the ordinary case, not a fault, so it withholds rather than refusing.
Why jj
The two rules ask two things of version control: every step of an agent's work is an immutable, addressable commit, including the in-progress one; and agents working in parallel can build on and merge into each other's branches without getting stuck. That pair is jj's native model. Expressed on git, the same rules cost a whole apparatus of simulation — commit-on-every-call with hard resets, force-with-lease pushes, per-sibling rebase notifications, manual fast-forwards — because git's conflicts block: a rebase that hits one stops mid-operation and waits for a human. jj records conflicts inside commits instead, so a conflicting rebase still succeeds and descendants auto-rebase; there is no mid-operation state to sit in.
| What the rules demand | Simulated on git | Native in jj |
|---|---|---|
| Every step an addressable commit | commit-per-call + worktree==HEAD + reset-to-HEAD | the working copy is a commit, auto-snapshotted every command |
| Conflicts that never block | dirty-worktree escape hatch + base-diff notifications + force-with-lease + per-sibling rebase | conflicts recorded inside commits; a conflicting rebase still succeeds; descendants auto-rebase |
| Topology that propagates | base branch frozen per job + manual fast-forward + "go rebase" messages | one shared commit graph + bookmarks that auto-follow rewrites |
Because version control is fully recoverable — every version of a branch stays in the shared graph, recorded against the agent that made it — it is the one boundary the sandbox leaves open. An agent runs confined, but it can run version-control operations freely, push included, because the worst it can reach is its own job's branch.
Delivery: push, merge, and integration
Publishing a commit is three rungs, not one, and they write three different things: the bookmark in the jj store, then refs/heads/<branch> in the backing repository (which is the only ref anything outside jj reads — git rev-parse, a push, GitHub's view of a pull-request head), then origin. The push is required while a remote pull request is open on the branch and deferred otherwise, so an in-flight pull request stays mirrored and visible throughout the run.
Every rung fails closed. A commit whose export or push does not land is reported as sealed locally; unpublished, with the error — never as committed. That matters more than it sounds: silently climbing only the first rung is how an agent can make commits that report success while the branch everyone else reads stays exactly where it was.
A finished agent produces a "create pull request" result, and Cairn opens or updates the pull request on GitHub. Merging runs over the shared store, and a Coordinator shows the machinery best: its child issues merge into the Coordinator's integration branch, not trunk. The child's real commit is folded onto the integration branch by fast-forward — the actual commit, not a squash — and origin's ref advances to match, which is what marks the child's pull request merged.
trunk (main)
│
o── agent/CAIRN-50-coordinator-0 ← integration branch
│╲
│ ╲── agent/CAIRN-51-builder-0 child, branched from the integration branch
│ ╲── agent/CAIRN-52-builder-0 child, branched from the integration branch
│
children fold INTO the integration branch, not trunk.
Because the whole project shares one commit graph, that fold ripples outward:
- Siblings auto-rebase. Every other in-flight child branched from the same integration branch is rebased onto the new tip over the shared store, and the cleanly-rebased ones are pushed. A sibling that rebases into a genuine conflict is woken with a note to resolve it and re-seal; jj refuses to push a commit carrying a conflict, so a conflicted branch can't advance a shared ref until it's resolved.
- The integration branch is advanced. The Coordinator sitting on the branch the child folded into has its working copy re-parented onto the new tip and refreshed.
Both ways a merge can happen — clicked inside Cairn or merged on GitHub — run through the same reconciliation, so an external merge settles exactly like an in-app one.
When a job ends, teardown reclaims its scratch directory and its residency. There is no agent checkout to tear down, and branch deletion is governed separately, by whether the work landed and closed. Teardown drops bookkeeping, not commits: every sealed commit stays in the project's object store, which is what lets a finished transcript be reconstructed long after the job is gone.
Transcripts outlive the job
Those surviving commits do one more job. While a job runs, its transcript is stored full in the database. At teardown, the events that referred to files — the agent read X, wrote Y — are rewritten into coordinates: a commit plus a path, anchored on jj's stable change-id and replayed against the project's object store on demand. A read becomes the commit it read; a write becomes the diff of the commit it made. Before dropping a read's stored bytes, Cairn re-renders the file from its commit and compares byte for byte, so a coordinate is proven to reproduce the original while the original still exists.
Teardown deletes the branch those commits live on, so before it goes, Cairn copies the objects the job uniquely created — just its own new commits and file versions — into a per-execution pack stored in the database; everything else stays safe in the main repository. Reconstruction layers that pack over the project's object database and resolves each lookup top-down, so an archived transcript stays readable no matter what git later collects. Every reader passes through one reconstruction step, so archival is a property of storage, not of reading.