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
- describe the work
jj new
to create a new empty changejj squash
to push changes into the described change
- Edit workflow
- Create a new change
- 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 currentjj
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 thatjj
hasn’t captured them in the current change, though! You can confirm this with agit diff
and ajj 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:
jj config list --include-defaults
jj/cli/src/config/
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
- Jujutsu Docs, the official docs
- Steve’s Jujutsu Tutorial, the best tutorial
- r/rust: Jujutsu VCS tutorial that doesn’t rely on Git, a quick non-git-specific overview of basic jj concepts
- jj init - What if we could actually replace git? Jujutsu might give us a real shot
- A Better Merge Workflow with Jujutsu shows some of the complicated multi-branch and rebase wrangling you can do with
jj
- Understanding revsets for a better jj log output
- jj-vcs/jj#2425: Working branches and the JJ “way”
- jj-vcs/jj#5568: Find the closest bookmark in history to advance it forward, describes the
tug
alias and suggests some other more extensible versions - jj-vcs/jj#5812: How do core Jujutsu developers configure Jujutsu? as an analog to How Core Git Developers Config Git
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.