Intro🔗
I’ve been learning Jujutsu (jj), a a version control system that serves as a frontend for git, but changes the workflow itself instead of just being a GUI or a set of aliases.
The thing is cool for many reasons, but the most important one is that workflows like rebase, splitting a commit, changing history or resolving conflicts are so, so much easier than with git. You can see this in I want to move added changes in a working copy ("currently checked out commit") into a parent change and I have a working copy with too many changes and I want to split it into multiple. It also has many quality of life features, like autorebase, autostash, a literal undo and redo command, the default behavior for squashing sends current changes to the parent commit (which is probably 90% of my squash usage pretty much), and it comes with nice TUIs for split, squash, and resolving conflicts.
It has facilitated my git workflow immensely, but it also scared me when it comes to some design decisions. After all, I’m familiar with git and know how to avoid screwing things up with it; I was unfamiliar with jujutsu and didn’t know how to not screw up there.
When I tried to apply it to my KDE workflow, I learned about what it does that I don’t want. Here’s my notes about it, in case anyone is interested in trying it.
For the record, I've done all the following workflows before:
- keep all changes in semantically split commits regardless of file
- keep each file into its own commit <-- this is what I do most nowadays because I docs is easier this way
- squash everything into one commit
- make a single large commit, then split it into multiple
Maybe I'll write about this in the future, we'll see. 
Configuration🔗
This section explains what I feel are required changes to the default Jujutsu settings so it matches my usage.
I don't want jj to track main/master unless I do so explicitly🔗
Jujutsu has a concept of “tracked bookmarks” (where a bookmark is essentially a git branch for our purposes). When you do track a bookmark, every time you push to a remote, all tracked bookmarks get pushed at the same time.
While this is handy for most people (like those who have their own small projects and don't need branches), well, in KDE we usually don’t ever commit to master unless the change is tiny and uncontroversial; instead we put our work in work/<username>/branch-name (a work branch) and make a merge request. And jujutsu defaults to tracking master/main.
If a contributor has the ability to commit to master, this may be a hazard. I can easily see someone making a commit in their local master branch for testing or whatever reason (bisecting? triggering tool behavior? I don't remember what for, but I've done this before), forgetting about it after some time, and then being surprised they pushed to master.
This may be an irrational fear, but I’d rather the tool cater to my own stupidity instead. Hence, I disable tracking the master branch.
[git]
track-default-bookmark-on-clone = false
I don't want jj to automatically track bookmarks at all🔗
I once tried this setting after being confused at the official documentation, whose listed use cases seemed beneficial to me; in reality I don’t want it at all, and I spent quite some time trying to figure out why Jujutsu was not giving me the behavior I wanted.
As mentioned above, every tracked bookmark gets pushed by default when you do a generic jj git push without specifying a bookmark. Like in I don't want jj to track main/master unless I do so explicitly, there can be an accident where you accidentally push a change made to some branch you didn’t intend to push. This is probably more likely to happen here, in fact.
So the jujutsu defaults for this are fine, actually, and I just make a deliberate note here to avoid setting this up, and if I ever do, it will only happen with work branches.
# [remotes-origin]
# auto-track-bookmarks = "work/username/*"
I want jj to advance the bookmark as soon as I commit🔗
Jujutsu doesn’t have a concept of “current branch”. Instead, you work on “detached commits”, in git lingo, and just make a bookmark point to the tip of the last detached commit.
This is of course incredibly annoying to do every time you want to push your branch, so the jujutsu folks implemented a feature to do this: advance branches.
Every time you use jj commit, your branch is automatically advanced; in other words, the new commit becomes the tip of the branch.
For this I want two settings.
I want jj to only advance the bookmark in my work branches (I sometimes work on other people’s branches, I don’t want to push them to the remote accidentally):
[experimental-advance-branches]
enabled-branches = ["glob:work/username/*"]
I don't want jj to ever move the bookmark on main/master unless I do so explicitly (again, for my (irrational) fear of accidentally committing something to master):
[experimental-advance-branches]
disabled-branches = ["main", "glob:push-*"]
When I do want to have these settings to be different, I can just jj config edit --repo to apply that config to my specific repo. I do this for this blog's repository, for example.
I want more descriptive autogenerated bookmarks🔗
Jujutsu does a neat thing where if you never specify a bookmark/branch name, it will simply autogenerate one for you in the push-randomletters format.
I can see why people would want that (in principle you don't need to care about branch names if you always do PRs/MRs immediately), but I don’t want it. It’s ugly and non-descriptive. While not exactly often, KDE contributors sometimes make branches that don’t get an MR until later in life; if I were to browse a list of non-descriptive branch names, it would be more annoying than helpful.
If it ever happens, I want it to be at least somewhat descriptive. So...
In the case I ever intentionally or accidentally push an autogenerated bookmark, I want jj to at least have a more readable default autogenerated branch name based on the commit name without spaces and without single quotes in lowercase:
templates.git_push_bookmark = "\"work/username/\" ++ description.first_line().replace(\" \", \"-\").replace(\"'\", \"\").lower()"
There’s a bit of quote witchcraft going on because TOML, but this means the generated bookmark for a commit named Add setup page would be named work/username/add-setup-page.
This does have the limitation of being based on the last commit (I think?), but come on, I'm not that good with jj yet. 
Resulting configuration🔗
The result, including username, email and ssh signing set up, resides in ~/.config/jj/config.toml:
[user]
name = "Your User"
email = "your-email@example.com"
[git]
# Don't want to track main/master
track-default-bookmark-on-clone = false
# [remotes.origin]
# Generic `jj git push` sends it to all tracked bookmarks
# auto-track-bookmarks = "work/username/*"
[templates]
# Any autogenerated bookmarks become work/username/add-setup-page
git_push_bookmark = "\"work/username/\" ++ description.first_line().replace(\" \", \"-\").replace(\"'\", \"\").lower()"
[experimental-advance-branches]
## Automatically runs jj bookmark advance on commit
enabled-branches = ["glob:work/username/*"]
disabled-branches = ["main", "glob:push-*"]
[signing]
behavior = "own"
backend = "ssh"
key = "~/.ssh/mysshkey.pub"
Instead of manually specifying the path to the file, you can just run jj config edit --user.
If you want a programmatic way to set these settings, there's jj config set --user <name> <value>. For example: jj config set --user git.track-default-bookmark-on-clone false.
Workflow🔗
I’m not going to explain how jujutsu works in this blog post.
I am going to link you to the official documentation and to Steve Klabnik’s Jujutsu tutorial instead.
Here in this blog post I’m simply going to show you the jujutsu commands I use for my workflow, based on actions, so you know what you need to do in these situations.
I want to turn an existing cloned git repo into something I can use jj on🔗
jj git init
I want to clone a repo and have it ready for use with jj🔗
jj git clone <repo-link>
I want to create a new branch, push it, and start working on it🔗
jj bookmark create work/username/i18n
jj git push --bookmark work/username/i18n
jj bookmark track work/username/i18n@origin
It’s necessary to track the bookmark so it gets pushed with jj git push later on. Otherwise you may simply jj git push --bookmark <bookmarkname> every time.
I want to create a new empty working copy🔗
jj new
I want to “checkout” (switch?) to a commit, change or branch, no matter if detached or not🔗
jj edit <change-or-bookmark>
This is great. You don’t need to do the whole git dance to edit a previous commit at all:
- git log
- git rebase --interactive
- mark as edit/e
- make a change
- git rebase --continue
You just jj edit, edit what you want, then you jj edit back to the tip/branch. All subsequent commits are automatically rebased for you, and jujutsu will warn you of any conflicts.
I want to turn a change into an actual commit and have it be the latest tip of my branch (I’m already working on a branch as set up earlier)🔗
jj new
# Make a change
jj describe
jj bookmark advance
Or, with experimental-advance-branches:
jj new
# Make a change
jj commit
I recommend using jj commit, but it’s good to know that it’s equivalent to jj describe + jj bookmark advance.
I want to move added changes in a working copy ("currently checked out commit") into a parent change🔗
jj squash
# That's the same as
jj squash --from @ --into @-
This is great because I feel the most common use case for a squash is from child to parent. You’d normally do git rebase --interactive and mark a commit as squash/s for this. Here you don’t even need to check the log or rely on tab completion.
Convenient if you’re the type to squash all changes into a single commit, but you really want to jj diff your working copy first to make sure your changes are how you want them.
Instead of switching to a change and editing it, I want to move added changes in a working copy to a specific change, interactively🔗
jj squash --interactive --into <change-id>
# This will modify that change's change-id btw!
# Don't reuse the same id from shell history.
If you know the change ID or commit you want to squash into, you can actually use it with interactive mode, which provides you a nice TUI to select the exact things you want to transfer.
I made a new commit (which automatically creates an empty working copy) and want to merge the previous commit into its parent🔗
# If I don't want to leave the current working copy:
jj squash --from @- --into @--
# Or if I do leave the current working copy:
jj edit @-
jj squash
Here @ is the current working copy (like git’s HEAD) and @- is the working copy’s parent (like HEAD~ or HEAD~1). There’s stuff like @-- which is the parent of the parent too (like HEAD~2).
I have a working copy with too many changes and I want to split it into multiple🔗
jj split --interactive
It doesn't matter if your working copy is an existing named commit or if it's an anonymous working copy.
Let's say your case is the former: after splitting the changes, the ones that were marked will be the only things in the current named commit; the ones that you did not mark will be in a separate commit.
Let's say your case is the latter: after splitting the changes, the ones that were marked will be part of the current working copy, which will get a new name like if you did jj describe; the ones you did not mark will be in a separate commit.
Split does not create a new empty working copy (because you might want to split the commit further).
I want to get back to the parent change🔗
jj edit @-
I want to stash changes in my unnamed working copy and edit something else🔗
jj edit <commit>
Yeah, you don't really do anything. You can just leave that commit and go back to it later, it's autostashed for you. See the next section.
I want to get back to the child change (that's not an empty working copy)🔗
jj edit @-
# Finished, I want to go back!
jj edit @+
Of course, if the child change was an empty working copy, then this command fails with Error: Revset '@+' didn't resolve to any revisions, and you'll need jj new.
However, if there was work in an unnamed working copy before you moved to a parent commit, then trying to edit the child change actually will restore the working copy you had (with a different change id). You can think of this as jj essentially stashing your working copy changes, and restoring them immediately upon editing it again.
Let's think of a similar hypothetical scenario where, after doing jj edit @-, instead of doing jj edit @+ you were to do jj new. What happens then? Rather than abandon the changes, you branch it. The parent @- will actually have two unnamed children (that you can edit with their change ids), and jj log will show this.
This distinction is only briefly mentioned in the official docs despite being awesome, so I wanted to elaborate it here.
I am in an edited working copy but I want to drop changes while keeping the bookmark pointer if there is any🔗
jj abandon --retain-bookmarks
You probably always want to use --retain-bookmarks. Might as well keep that in your shell history instead of jj abandon.
Using jj abandon with experimental-advance-branches means the tip loses the bookmark, and you’ll need to mark it again. With jj abandon --retain bookmarks the bookmark is sent to the previous commit. I wish that was the default when I have experimental-advance-branches enabled.
I made an error that might have been destructive in my repo or history and I need to correct it🔗
# Acts as a literal ctrl + z
jj undo
This reverts the last operation in jj operation log.
Particularly good when you’re resolving a conflict and you accidentally confirm the changes instead of quitting!
If the undo operation itself was a mistake, you can fix that with jj redo, which is a literal ctrl + y.
I wish I had that in git when I was starting... I've had so much trouble even trying to read the damn git reflog. 
I want to change the order of my commits🔗
jj arrange
This is usually done with git rebase --interactive. It’s fine. I like that it semantically makes sense, though it takes a bit of effort to lose the muscle memory.
I want to rebase my changes onto up-to-date master🔗
jj git fetch
jj rebase --onto master@origin
No need to git pull --rebase origin master or update your local master branch to in turn rebase your branch locally, jj uses the remote reference directly. It just needs to know what has changed in the remote beforehand (hence the git fetch).
By the way, in KDE we rarely ever do merge commits, or that rebase process involving "merge master into commit" or something. In fact, I'm so used to our workflow that I actually don't know how to rebase that other way!
I want to resolve conflicts that originate from a rebase🔗
# Upon attempting to rebase, an error message will pop up
# Saying to do exactly the following
jj new <first-commit-with-conflicts>
jj resolve
jj diff
# If it's resolved
jj squash
# Continue resolving until everything is done
jj edit <bookmark>
jj git push --bookmark <branch you were working on>
Probably the most elaborate process here.
It helps that jj resolve gives you a nice TUI with individual changed lines with context, like a git frontend TUI would.
I want to view only the log for my current bookmark🔗
jj log --revision 'trunk()..@'
This is similar to:
git log master..HEAD
In both jj and git it can be annoying to see the whole log, especially if the last commit on master/main was your own.