Jujutsu in colocated mode: where Git shows through
In the last post I made the
case that you can adopt jj without telling anyone, because it runs on top of
your existing Git repo in colocated mode: .git and .jj side by side, push
and fetch still going through Git. Turns out, colocated mode has seams.
A seam is a moment where Git and jj look at the same repository and describe
it differently. Git says the working tree is dirty; jj says everything’s
committed. A routine fetch reports that it “abandoned” a commit. git am
refuses to run. Each looks alarming the first time, and each turns out to be
harmless once you understand what Git and jj are each describing.
After a few weeks of working with jj, I’ve stopped being startled by them.
This post documents the seams I hit, why they appear, and the one move that
resolves each.
tl;dr; in colocated mode, trust jj’s view of the repo.
When Git looks panicked, it’s almost always describing a state jj considers
perfectly healthy.
First move after colocate: track your remotes
The first time I ran jj git init --colocate on an existing work repo, the
output was a wall of hints:
❯ jj git init --colocate
Done importing changes from the underlying Git repo.
Setting the revset alias `trunk()` to `main@origin`
Hint: The following remote bookmarks aren't associated with the existing local bookmarks:
develop@origin
main@origin
feature-x@origin
Hint: Run the following command to keep local bookmarks updated on future pulls:
jj bookmark track develop --remote=origin
jj bookmark track main --remote=origin
jj bookmark track feature-x --remote=origin
Initialized repo in "."
Hint: Running `git clean -xdf` will remove `.jj/`!
Two things to note here. First,
Setting the revset alias trunk() to main@origin: jj looked at the remote,
picked the default branch, and pointed the built-in trunk() revset at it.
trunk() is referenced by a lot of default templates and aliases (the default
jj log revset, the “show me my work that isn’t upstream yet” queries), so it’s
worth knowing what it resolves to. If jj can’t infer it, set it yourself in
~/.config/jj/config.toml:
[revset-aliases]
"trunk()" = "develop@origin"
Second, and more important on day one: in jj, main and main@origin are two
separate bookmarks that happen to share a name. The first is local; the second
is where main sat on the remote as of the last fetch. They can drift. This
mirrors Git’s main versus origin/main, but jj puts the distinction right
in front of you. A local bookmark tracks a remote one when you want fetches to
carry the local bookmark forward. A fresh colocate imports the remote bookmarks
but doesn’t auto-track them; that’s what the hints are nagging you to do.
Rather than type out each name, track them all in one go:
$ jj bookmark track 'glob:*' --remote=origin
Started tracking 4 remote bookmarks.
On a small repo, track everything. On a monorepo with hundreds of stale feature
branches on origin, be selective, or jj log gets noisy. Either way, the init
hints are essentially a setup checklist: read them rather than scrolling past.
“Abandoned N commits” on a routine fetch
A few days in, a plain fetch told me it had abandoned a commit:
❯ jj git fetch
bookmark: dependabot/github_actions/actions-bd3b3a0e0a@origin [deleted] untracked
Abandoned 1 commits that are no longer reachable:
kspkspwr ad24e4bf dependabot/github_actions/actions-bd3b3a0e0a@git | Bump the actions group with 4 updates
“Abandoned” is a strong word, and my first reaction was did I just lose
something. I hadn’t. What happened: the dependabot PR merged into main, and
GitHub auto-deleted the source branch on origin. The fetch picked up that
deletion, the remote bookmark was marked [deleted], and the commit at the tip
of that branch became unreachable from any bookmark or working copy, so jj
dropped it from the graph.
The @git suffix on the second line is the detail worth understanding. In a
colocated repo, jj exposes the underlying Git repo’s own refs as if they were
a remote called git. When origin deleted the branch, the matching @git ref
went away too, and the commit fell off.
Crucially, abandoned is recoverable. It isn’t a hard delete; the commit’s
still in the object store and the operation log. jj undo reverses the fetch’s
effect on the graph, and jj op log lets you find any earlier state. In this
case the dependabot changes were already on main under merge-commit IDs, so
the abandon was exactly right: it’s the housekeeping I’d otherwise do by hand
with git remote prune origin. Seeing Abandoned N commits after a fetch is
normal whenever upstream branches get deleted. Worth a glance to confirm it’s
expected, but not the alarm the wording suggests.
Git’s HEAD sits one commit behind jj’s @
This is the seam that unlocks most of the others, so it’s worth slowing down on.
After pushing a bookmark, my shell prompt still showed [!] (dirty working
tree), and git status listed every file in @ as added or modified:
❯ git st
## HEAD (no branch)
A proj/apps/proj/schedulers/api_backend.py
...
M proj/proj/settings/common.py
…while jj was perfectly content:
❯ jj st
Working copy (@) : nkowpylv e81bbc8c ticket-123/api-backend | Add API backend
Parent commit (@-): kptprsrn b9d9232e docs: Enable ...
First reaction: “did the push leave the repo half-broken?” No. This is just how colocated mode works.
Git’s HEAD is intentionally kept one commit behind jj’s @. In colocated
mode, jj parks Git’s HEAD at @- (the parent of the working-copy commit) and
exposes @’s changes as Git’s working tree state. From Git’s vantage point,
those changes are uncommitted, which is exactly what git status is
reporting.
jj does this deliberately. The “working copy is a commit” model treats @ as
something you’re still authoring, even after you’ve described it. If Git’s HEAD
pointed at @, ordinary Git commands like git commit --amend and
git reset would collide with jj’s snapshot-on-next-command behaviour. By
keeping HEAD at @- and surfacing @’s contents as the working tree, the two
tools stay coherent: jj says “this is a commit,” Git says “this is uncommitted
work on top of HEAD.” Both are describing the same state.
The prompt’s [!] is Starship (or similar) reading Git’s view and flagging a
dirty tree, accurate from Git’s side but harmless in practice. It clears the
moment you jj new off @: that promotes the old @ to a finalised commit in
Git’s eyes, HEAD advances, and the new empty @ is clean in both worlds. (The
(no branch) in git st is the same story from another angle: Git is on a
detached HEAD and has no idea what a jj bookmark is, even though the
underlying ref exists on disk and the push moved it.)
Once you know about the one-commit offset, reach for jj st when you want
to know what state the repo is actually in. git status looking dirty after a
jj operation is expected and needs no fixing.
“Dirty index” when Git tries to apply patches
That offset has a direct consequence. Trying to apply patches with git am:
❯ git am 0001-...patch 0002-...patch 0003-...patch
fatal: Dirty index: cannot apply patches (dirty: 0001-...patch 0002-...patch 0003-...patch)
The error is confusing because the parenthetical lists the patch filenames,
making it look like the patches are “dirty.” They aren’t. Git is saying its view
of the working tree differs from HEAD, and as we established above, in
colocated mode it does whenever @ has uncommitted-looking content, which is
most of the time you’re working. git am notices and refuses to apply patches
over what it sees as uncommitted state.
The fix is to force HEAD and working tree back into agreement first. The safest
version is a fresh empty @:
jj new # creates a fresh empty @; HEAD now matches working tree
git am 0001-*.patch 0002-*.patch 0003-*.patch
The empty @ guarantees a clean slate: the patches land as new Git commits on
top, and jj picks them up with proper change IDs on the next command. (If you
have pending edits to keep, any jj command, even jj st, forces a snapshot
first, which is enough to satisfy git am.)
One thing to remember afterwards: your bookmark won’t have moved. Git created
commits on top of HEAD, but jj bookmarks only move when you tell them to.
Nudge it forward if you want it on the new tip:
jj bookmark move <name> --to @
The general rule: any time a Git subcommand complains about a “dirty” working
tree in a colocated repo, reach for jj new or jj st before digging into
what’s actually dirty. The mismatch is structural; your changes are fine.
Where jj hands the job back to Git
None of this makes colocated mode a leaky abstraction. The seams are the points
where jj defers to Git rather than reimplementing parts of Git that already
work.
The clearest example is producing patches for someone outside the repo.
jj diff --git --from X --to Y > file.patch gives you a single combined diff
covering a range of commits, ideal when the recipient will git apply it.
But the git format-patch workflow (one mbox-format file per commit, with
From: / Subject: / Date: headers) has no jj equivalent, and it’s what
most upstream maintainers expect when you mail a patch. So you drop into the
colocated repo and use Git directly:
git format-patch f7db1946^..bbea56a3
# produces 0001-Begin-....patch, 0002-Complete-....patch
(I’ve written up the full patch-creation recipe, including anonymising a diff before sharing it, as a separate note.)
That’s the mental model that makes colocated mode comfortable. The two tools
share one object store and split the labour: jj owns the working copy, the
operation log, and history editing; Git owns the wire protocol and the corners
jj hasn’t covered yet. Using a colocated repo is the intended way to run jj
on an existing Git project. When Git’s view and jj’s view diverge, you’ve hit
one of these boundaries, and the fix is nearly always to trust jj and run a
jj command.