Understanding Jujutsu bookmarks
Jujutsu ("jj") sits atop a Git repository and its commands mostly mirror into Git operations; for example, a jj commit is a Git commit.
When collaborating with others with Git you push and pull branches. Meanwhile, jj has a feature called "bookmarks" that are the mechanism for working with Git branches, but which have fairly different behavior from Git branches.
This post goes into the why and how to use bookmarks for Git collaboration.
Why doesn't jj just use branches like Git does?
Part of jj's whole deal is that it collapses many Git concepts (stashes, staging, fixups, in-progress rebases, conflicts) into a single unified model of working with history, which then lets you use the same tools to do all of those things. For example, to fix up an old commit you jump to it, edit it, and jump back to where you were; to fix a rebase conflict you jump to the conflicting commit, edit it, and jump back to where you were, using the same commands.
All this jumping around means that the Git idea of being "on" a particular branch does not make sense in jj. When working on a change I might stop part way through doing one thing, start a different thing based on a commit a few steps back, possibly reshuffle commits around, and have a few extra commits on the side with experiments lingering around as well. Based on my former Git expertise I might have done this kind of thing by making a bunch of Git stashes and branches.
Instead, in jj when you work you are "on" a commit, and when you switch you switch between commits, not branches. After a year of using jj I can assure you that not having branch names for these has worked out just fine.
Bookmarks
Like a Git branch, a jj bookmark is a name that points to a commit, and there are the commands you'd expect to create/delete/rename and move bookmarks around. Unlike Git branches, bookmarks are fixed to a commit unless you manually move them; when you create new commits jj does not automatically move bookmarks around.
In my experience with jj, I have had no use for bookmarks other than for interacting with Git. In principle you could use them to make note of important commits, which I suppose is where the name comes from. Maybe other people have different workflows.
In a colocated jj/Git repository (which is the normal way to use jj), bookmarks are 1:1 with Git branches: creations/modifications/etc via either system are reflected in the other.
Remote bookmarks and tracking
After cloning a Git repository, jj creates "remote" bookmarks with names like
main@origin
. These are immutable and represent the state of the remote
repository.
You could also make a local bookmark named main
that is wholly independent.
But on a fresh clone, the local bookmark main
is marked as tracking
main@origin
. Conceptually this is similar to Git's notion of an "upstream"
branch, but with different behavior.
Suppose main
is a tracking bookmark. jj attempts to keep it in sync with
main@origin
:
- when you
jj git push
, ifmain
is ahead ofmain@origin
, jj pushes the changes. (When you're in a state where a push would make a change, jj status shows the bookmark name asmain*
.) - when you
jj git fetch
, jj updatesmain@origin
as well as updates your localmain
if it's behind.
Conflicts
If after a fetch the two sides diverge (both contain commits), then the local
main
will be marked as conflicting and point to both commits. This displays
in status as main??
. You will need to manually choose where it points with
jj bookmark set main -r ...
to fix it before using it again.
At least for me this was super weird at first, but now makes so much sense that I cannot remember why I was confused. I think the right way to think about it is that a tracking bookmark is modeling "what I intend this bookmark to be, both locally and remotely" and the jj push/fetch commands keep that in sync.
As distinct from Git, note there is no separate "fetch" vs "pull" commands. (A historical note: apparently both "fetch" vs "pull" commands existed in Git and Mercurial. They agreed that one meant "download the changes" and the other meant "do that and also merge them", but they flipped the meanings!)
Workflow: working alone
If you are just making changes locally and just want to push your changes to
main, you must update the bookmark before pushing with a command like
jj bookmark set main -r @
. This is currently the clunkiest part of jj. There
have been conversations in the project about how to improve it.
If you search for jj tug
online you will see a common alias people set up to
automate this.
Workflow: just put my code in
If you are comfortable with Git push syntax, an alternative I use for when I just want to push my code is to tell Git exactly what I want to push and where to put it:
$ git push origin SOMEHASH:main
Note this is plain git push
, no jj or any bookmarks involved.
Workflow: pushing branches
If you want to push a bookmark/branch for someone else to review or pull, the commands are:
$ jj bookmark create some-name
$ jj git push
(The second command will complain that some-name
does not exist remotely, and
then tell you how to fix it. There are flags for specifying which remote to push
to etc.)
Workflow: anonymous branches
Typically in jj you won't have bookmark names ready when you're sending off code reviews. To simplify things jj can generate a bookmark name for you as it pushes.
$ jj git push -c @
Creating bookmark push-sytrsqlnznzr for revision sytrsqlnznzr
Changes to push to origin:
Add bookmark push-sytrsqlnznzr to 5865f9673d0f
This is my primary workflow when working on GitHub, even solo. Pushing changes in a pull request lets the CI run over it.
Branch safety
jj treats history as mutable, making it natural to edit and reorder commits as you work. When collaborating with others, modifying history can be confusing or dangerous.
jj has a notion of "immutable" commits, which is the part of history that should not be modfied. In the default configuration this effectively means code that has been pushed to Git cannot be modified, with the exeception of code in tracked bookmarks. This means that you can continue modify a branch after pushing it, for example in response to code reviews. The next push will update it.
There are further safety checks around things like not letting you move a branch backwards (because that would trim off the later commits). In practice I don't understand all the rules, and sometimes it will prompt me to pass a flag to say "I really do mean to do this". It has been fine so far.
Bonus cool thing: plural revsets
(This final section is trivia and only interesting because I came to understand it when writing this post.)
jj commands that accept commits take a "revset" argument, which is the little
language for specifying commits. For example you can say jj diff -r @-
to see
the diff of the previous commit; the @-
expression means "parent of the
current commit". As the name suggests, revsets can refer to sets of commits. (jj
really ought to pick either "commit" or "revision" for talking about things,
it's confusing to have these as synonyms!)
When a branch is conflicting, the revset it names refers to multiple commits:
the commit you had locally and the commit seen remotely. Meanwhile, note that
the command to create a merge in jj is to create a commit with multiple parents,
jj new parent1 parent2 ...
.
Putting these together, with a conflicting main??
bookmark, you can do:
jj diff -r main
to show a diff of what the merge of the two commits would look likejj new -r all:main
to create a merge commit of the two commits (where the "all:" prefix means something like "I really do mean for this to refer to multiple commits"; looks like they're still figuring out how this should work)
I have never had a reason to need this trivia but it is kind of neat to see how these pieces fit together.