zerowidth positive lookahead

jj tips and tricks

A collection of tips and tricks for the Jujutsu version control system

Here are some of the tips, tricks, guidelines, and recommendations I’ve learned so far for using Jujutsu well. For more background, see the previous post, what I’ve learned from jj.

I’m using “commit” and “change” interchangeably here. Remember that a jj change is durable and evolves over time, while the underlying commit associated with a change might be different.

Workflows

All new, all the time

Use jj new everywhere unless you really mean to change something in place. And even then, jj new with a jj squash is just as easy, and safer too because you get to decide if and when to squash those changes.

jj automatically abandons empty/undescribed changes, so if you move elsewhere in the repo any temporary jj new change will be cleaned up behind you.

Common workflows

There are two common workflows for making new changes described by the jj community, explained in Steve Klabnik’s tutorial:

  • Squash workflow
    1. describe the work
    2. jj new to create a new empty change
    3. jj squash to push changes into the described change
  • Edit workflow
    1. Create a new change
    2. If the change needs to be split up, create a new change before the current one, then work on that until it’s right, then continue on the main change

Steve also mentions a “one change per PR” style in jj-vcs/jj#2425: Working branches and the JJ “way”

These two workflows are somewhat unfamiliar, but I’m curious to see how they might relate to an in-repo mikado method.

Hunk-wise style

Meanwhile, I’m often using jj like I used git: hack on some code, then decide where to commit the work. I’d make edits, then use git add -p to incrementally stage the relevant subset for a new commit. This pattern works fine with jj commit --interactive, with one exception: the default TUI for interactive staging doesn’t work well for me. I much prefer the hunk-wise yes/no/edit/skip decisionmaking afforded by git add -p. Fortunately, @dubi steinkek in the jj discord implemented this:

[ui]
diff-editor = "gitpatch"

[merge-tools.gitpatch]
program = "sh"
edit-args = ["-c", '''
  set -eu
  rm -f "$right/JJ-INSTRUCTIONS"
  git -C "$left" init -q
  git -C "$left" add -A
  git -C "$left" commit -q -m baseline --allow-empty
  mv "$left/.git" "$right"
  git -C "$right" add --intent-to-add -A
  git -C "$right" add -p
  git -C "$right" diff-index --quiet --cached HEAD && { echo "No changes done, aborting split."; exit 1; }
  git -C "$right" commit -q -m split
  git -C "$right" restore . # undo changes in modified files
  git -C "$right" reset .   # undo --intent-to-add
  git -C "$right" clean -q -df # remove untracked files
''',
]

This add -p style also makes jj split --interactive and jj squash --interactive a lot easier. I tried to find a tool that would nicely handle 3-way merges with directories (not single files), and failed. This config gave me a familiar comfortable “handle” for the operations jj allows.

Which one first?

Start by using jj commit, or the equivalent, jj describe && jj new. Once you’re comfortable with the basics, try out squash and split, including their --interactive versions. Don’t be afraid of things like rebase, either, given jj undo’s safety net.

Git interoperability

Colocated mode

I recommend using jj in colocated mode, especially if you’re still dealing with a git upstream. Instead of jj git clone, use git clone && jj git init --colocated, or jj git init --colocated in an existing repository.

Advantages:

  • This will let you continue to use git commands if needed.
  • Editors with git integration will still be able to show you inline git annotations or use other features.
  • It’s easy to try jj without committing fully. You can remove the .jj folder and go back to git any time.

Some downsides or complexities:

  • git sees your current jj change as a “detached HEAD” state, which makes a shell prompt confusing or useless
  • You can do git status to see that (as far as git is concerned) there might be untracked changes in your repo. This doesn’t mean that jj hasn’t captured them in the current change, though! You can confirm this with a git diff and a jj status.
  • The rougher edges of git vs. jj are more visible. You might try going all-in on jj-only instead, as an experiment!

Remember that if anything ever seems out of sync, especially when dealing with branches and bookmarks, you can resync jj and the git repo with jj git import.

For more, see the docs about using jj with GitHub.

Bookmarks and branches

Bookmarks somewhat of a pain to use because they don’t auto-update like git branches. It’s nice that you don’t need named branches to do things with jj, but once a git remote is involved, you need bookmarks, and you have to update them yourself. A common configuration sets up a tug alias to help with this:

[revset-aliases]
'closest_bookmark(to)' = 'heads(::to & bookmarks())'

[aliases]
tug = ["bookmark", "move", "--from", "closest_bookmark(@-)", "--to", "@-"]

Once you’re happy with a series of changes on top of a bookmark, jj tug moves the nearest bookmark up to @-. There are some alternatives in the discussion post that make this more flexible or customizable.

The jj docs about bookmarks talks about how to work with bookmarks in much greater depth.

Immutable branches

GitHub pull requests don’t handle rebases and the resulting force-pushes well: review comments get lost or invalidated, so it’s harder to see what’s changing or what needs to be reviewed. For that reason, I’ve overridden jj‘s configuration for what is considered an “immutable change” to include any branch that’s been pushed remotely.

[revset-aliases]
# set all remote bookmarks (commits pushed to remote branches) to be immutable
'immutable_heads()' = "builtin_immutable_heads() & remote_bookmarks()"

Miscellaneous

Configs

To see how jj is configured, you can look at:

While the jj default configuration is fine, I recommend one change in particular:

[git]
push-new-bookmarks = true

This means you don’t need to specify --allow-new when pushing a new bookmark.

The unique prefix for a changeset is highlighted by default, but you can hide the unnecessary bits to make the jj log more concise:

[template-aliases]
'format_short_change_id(id)' = 'id.shortest()'

I figured out how to limit the jj log output to only show recent bookmarks, for long-lived repos with a lot of branches still around:

[ui]
default-command = "log-recent"

[aliases]
log-recent = ["log", "-r", "default() & recent()"]

[revset-aliases]
'recent()' = 'committer_date(after:"3 months ago")'

And a variety of aliases that I’m trying out to reduce typing:

[aliases]
c = ["commit"]
ci = ["commit", "--interactive"]
e = ["edit"]
i = ["git", "init", "--colocate"]
nb = ["bookmark", "create", "-r @-"] # "new bookmark"
pull = ["git", "fetch"]
push = ["git", "push", "--allow-new"]
r = ["rebase"]
s = ["squash"]
si = ["squash", "--interactive"]

Operation log

Remember you have jj op log to see the state of your repo over time. jj undo to go back a step means you can try rebase without concern; jj op restore (not jj restore!) to recover to an earlier state.

Rebases

To understand what rebase can do, jj rebase --help explains well. You can do a lot, not just rebasing the current branch somewhere new.

Working clean

When using git, my working copies were often a mess, leaving a bunch of untracked files around. Most of the time that was intentional, except when I forget to git add a new file I just created too. That’s no longer necessary, but jj captures everything. To continue to ignore a file in your repository after switching to jj, make sure it’s in .gitignore or .git/info/exclude, and then tell jj to forget about the file: jj file untrack <file>.

Replacing git stash

Any time you’ve made edits to a new, undescribed change, it’s preserved in the repository history. I suggest that any time you’ve made some changes that you might want to keep (and that you’d have previously stored away with git stash), you at least add a description: jj describe -m "wip for..." so you have some context when you see it in the history later.

Resources and reading

What’s next?

I haven’t tried any of the GUI/TUI tools yet, such as VisualJJ for VSCode, Jujutsu Kaizen for VSCode, or jj-fzf for the terminal. I’m also planning to look at better integration with the GitHub CLI for creating a bookmark and then opening a pull request related to it in a single command.

I’m not sure yet what a good shell prompt with jj looks like, but I’d like to try some things out, including integration with my p10k zsh prompt.