Worktrees, GitButler, Jujutsu: finding a version control model for parallel AI agents
I run AI coding agents in parallel. Several at once, on the same repo, each chewing on a different change while I review, test and steer. It's a genuinely different workflow from one-human-one-branch, and it broke my git habits fast.
The core need isn't just "isolate some throwaway branches." It's this: I want several long-lived lines of change that I keep working on and testing at the same time, I want to deal with conflicts there and then rather than discovering them at some upstream merge weeks later, and I want rebasing to be seamless rather than a chore I dread. I went through three tools chasing that. This is what happened with each.
git worktrees: the boring default that didn't fit
Worktrees are the obvious first answer, and they're what Claude Code,
Codex and Cursor all support natively. Each worktree is a separate
directory backed by the same repo, so each agent gets true filesystem
isolation — its own checkout, its own node_modules, no stepping on
each other.
For execution isolation, they're great. For the workflow I actually wanted, they fell short. Worktrees isolate where code runs; they do nothing for how versions relate. I was still rebasing by hand, conflicts still surfaced late at merge time instead of when I wanted to deal with them, and keeping several long-lived changes coherent across N directories was bookkeeping, not a model. The isolation was real but the version-control story underneath was still just… git, with all its branch-switching ceremony.
GitButler: exactly the right idea
GitButler's virtual branches were precisely my mental model. Multiple branches applied to one working tree at the same time, changes assigned between them freely, reorder and restack without the switch-stash-switch dance. Conflicts and rebasing handled fluidly. For parallel work it felt purpose-built.
I liked it enough that I built a Neovim plugin for it —
gitbutler.nvim — a
buffer-based UI driving the but CLI entirely through but <command> --json, so I could manage virtual branches, assign files, commit,
absorb, squash and push without leaving my editor. It even surfaced the
operations log for snapshot-restore. I put real effort in.
Two things took me back off it.
The smaller one: it's GUI-centric by design, and I live in the terminal and Neovim. Building the plugin was partly an attempt to drag the model into my world — and the fact that I had to build a whole plugin to make it fit was, in hindsight, a signal.
The bigger one: instability. A version control tool is the one piece of software that has to be trustworthy above all else — it's holding your work. When the tool managing my history isn't something I fully trust, that's not a papercut, it's a dealbreaker. No feature set compensates for that.
There's also a structural wrinkle worth naming: with virtual branches, one working tree holds the union of everything applied at once. That's convenient for editing, but it means your tests can see a merged state that doesn't correspond to any single branch — which is exactly the wrong property when you're trying to get a trustworthy test signal from one agent's change in isolation.
Jujutsu: the model, without the instability
Jujutsu (jj) ended up being the answer, and the interesting thing is it doesn't sit as a third option on the worktrees-versus-GitButler spectrum — it collapses both halves into one tool. The "nice branch/commit layer" I wanted from GitButler and the "physical isolation" I wanted from worktrees are both native to jj. In the terminal. No GUI. And it's Rust.
A distinction that matters, because conflating these two is the trap GitButler fell into:
-
Organising versions is handled by jj's anonymous heads (bookmarks are just named pointers you attach when you need to push to git). Many concurrent lines of work, zero branch-switching ceremony. But in one workspace, the working copy is still a single revision at a time — so jj does not repeat the "one tree holds the merged union" problem. One checkout, one revision, but with frictionless switching.
-
Isolating execution is handled by jj workspaces — the direct equivalent of a worktree. A separate directory sharing the same underlying repo with its own working copy, so you get real filesystem isolation for separate
node_modulesand the like.
So the rule is simple: heads for organising versions, workspaces for isolating execution. Want trustworthy parallel test signal? Spin up a workspace per agent — same as worktrees — but now with jj's model on top.
Where jj actually wins for AI work
The killer property is the auto-snapshotting working copy. The working
copy is always a commit, and jj captures the current state
automatically. An agent can generate dozens of files and you never lose
work and never stage anything. Combine that with the operation log and
jj undo, and you get the best agent-mistake-recovery primitive of the
three: every state the agent passed through is in the op log, and undo
rewinds it losslessly. For a human-in-the-loop flow where you're
reviewing and occasionally reverting what an agent did, that beats both
raw worktrees (nothing but the reflog) and GitButler's undo (which I'd
be leaving the terminal to reach).
It also delivers the first-class conflicts GitButler markets: rebase always succeeds, conflicts are recorded in the commit and resolved later in any order, so restacking agent branches never blocks. And the commit surgery — split, squash, move changes between revisions, edit any commit and auto-restack its descendants — matches GitButler's drag-and-drop while staying in commands that fit tmux and Neovim natively. That was the whole thing I'd wanted: address conflicts now, rebase seamlessly, without trusting an unstable layer or leaving the terminal.
Two honest caveats
It isn't free of sharp edges, and pretending otherwise would be dishonest.
First, the snapshot model has no background daemon — jj snapshots when
you run a jj command. If an agent makes changes and crashes before any
jj command runs, those changes aren't yet in history. The fix is a hook:
a preexec that runs jj status before each command, or a Claude Code
PreToolUse hook running jj status (about 0.01s) so it snapshots before
each tool call. If you're wiring jj into an agent flow, that hook is
non-optional — bake it in from the start.
Second, the agent and editor ecosystem is younger than worktrees.
Worktrees have native support across the major agents; jj support is
catching up. The practical fix is teaching the agent the jj model
explicitly — a jj section in your CLAUDE.md (or equivalent) so the
agent doesn't reflexively reach for git add and git commit, and
knows to use the working-copy-as-commit model.
The quiet team advantage: colocated mode
The thing that sealed it: run jj in colocated mode and it keeps a real
.git repo underneath. GitHub, your CI, any teammate still on plain git
— none of them see anything unusual. You're adopting jj unilaterally, on
top of the existing git repo, transparently. No GUI to standardise on,
no buy-in required, no licensing question (jj is Apache-2.0). You get the
better model without imposing anything on anyone.
The ranking, for my use case
For "AI agents plus me, developing, testing and reviewing in parallel":
Jujutsu is the best fit. It matches worktrees on test isolation (via workspaces), beats raw git on the develop-and-review loop (auto-snapshot, op-log undo, painless commit surgery, first-class conflicts), is terminal-native and Rust, and adopts transparently over an existing git repo. The price is a small-but-real mental-model shift, the snapshot hook for agents, and a younger ecosystem you paper over with a jj skill in your agent config.
Worktrees remain the boring-safe default — most native agent support, nothing new to learn. The right call if you want zero adoption cost or a single obvious primitive for a team to share.
GitButler drops to third for this specific goal, because of the shared-tree test-signal problem and, for me, the stability. Its real edge is the GUI review experience — which, if you live in the terminal, jj's op log and commit surgery largely neutralise anyway.
On gitbutler.nvim
The plugin still works and it's still up. I'm glad I built it — it's
some of my better Lua, the --json-only architecture is clean, and it
taught me a lot about GitButler's model. But I've moved my own workflow
to jj, so I'll likely archive it before long. If you're a GitButler user
in Neovim, it's there and MIT-licensed; just know the author rides a
different horse now.
That's the honest arc: the model GitButler pitched was right, but the tool I could actually trust, in the environment I actually work in, turned out to be Jujutsu.
Rebuilding the GitButler gestures in jjui
Moving to jj didn't mean giving up the ergonomics that drew me to GitButler — it meant rebuilding them as terminal-native gestures. I use jjui as my interactive front-end and wired a set of custom actions into its config that reconstruct the GitButler moves I missed, plus a couple jj makes possible that GitButler never could.
The one that matters most is toggle simultaneous edit — my jj-native replacement for virtual branches. It manages a merge commit sitting over several branches at once, so I can edit them all in a single working copy, exactly like GitButler's applied-branches model. Toggling it on a selected set creates the simultaneous-edit merge; toggling a commit that's already in the merge removes it (dissolving the merge when it's the last one); toggling a commit outside the merge detaches it to trunk and adds it as a parent. The working copy stays parked on the merge throughout. That's GitButler's headline feature, reconstructed in jj, opt-in per moment rather than the permanent default — so when I don't want the merged-union test-signal problem, I just don't engage it.
The rest are quality-of-life gestures that lean on jj's auto-rebase:
- insert after creates an empty commit directly after the selected revision and lets jj auto-rebase the descendants onto it — slotting work into the middle of a stack with no manual rebasing.
- move here takes a multi-selected set of commits and rebases them onto the selected revision as their new parent. A "grab these, drop them here" gesture for reorganising a stack — the drag-and-drop feel, as a keypress.
- sync is local tidy-up in three steps:
jj absorbto push working-copy edits down into the ancestor commits that already touched those lines, then rebase the local stack onto the latesttrunk(), then forget bookmarks deleted on the remote. No fetch — purely "clean up what I have." - fetch & cleanup is the GitButler-style "pull trunk and drop my
integrated branches" move. It fetches only
main(deliberately not all branches, so merged feature branches don't flood back) and then removes the local bookmarks whose PRs have merged. No rebase, so it can't introduce conflicts. - open in editor resolves the previewed file to an absolute path and
hands it off to my running Neovim session via a watched handoff file,
falling back to
$EDITORif that's not available.
Behind the fetch-and-cleanup gesture is a small standalone script that
forgets local bookmarks whose PR has merged upstream. Rather than
guessing from empty commits, it asks GitHub directly (gh pr list --state merged) for the head branches of merged PRs — ground truth
across squash, rebase and merge merges alike — intersects that with my
local bookmarks minus trunk, and forgets each match. It only abandons
the underlying commit when it's a clean mutable leaf, failing closed on
anything divergent, conflicted, or carrying stacked work. It supports a
dry-run flag and a built-in self-test.
This is the part I'd point any GitButler refugee to: the model you liked isn't locked inside GitButler. With jj's auto-rebase and first-class conflicts doing the hard part underneath, a few hundred lines of config and one small script gets you the gestures back — in the terminal, on a foundation you can trust.