Understanding Jujutsu bookmarks

August 21, 2025

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:

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:

I have never had a reason to need this trivia but it is kind of neat to see how these pieces fit together.