What is jj?
Jujutsu (jj) is a distributed version control system (like git, hg, darcs, pijul, etc.).
However, it is rather unique in that it is git-compatible—it uses git as a storage layer, meaning you can use it right now on your existing git repos without disrupting anyone else.
It also provides other features, such as
- conflicts as a first-class object
- no explicit index
- revset language
- operation log and powerful undo
- automatic rebase and conflict resolution
It also clearly has a (currently small but) passionate group of users, which is a good sign of a useful tool.
Some of these users strongly dislike git, finding its user interface unintuitive and clunky.
I, however, am very much not part of that group.
I love git.
Over the years, I’ve curated a configuration that I find a joy to use.
I’ve built up years’ worth of muscle memory.
As a result, even though I love investing in interfaces, such as new software tools, I haven’t ever felt motivated enough to try jj.
That changed this past week when I learned that Steve Klabnik found jj so interesting that he’s leaving Oxide to pursue it further.
As an experiment, I’ve committed to using only jj for at least a couple of weeks.
As a git user, the tutorial that clicked best for me is Steve’s.
So, if you’re a git fan, I suggest starting there.
First impressions
What I miss from git
I quickly ran into two features that I wish were supported.
First, jj does not support submodules.
Sure, submodules are not great in many cases.
However, one very common case I use them for is Zola themes, such as the theme for this blog.
My customization lives in my repository, and the theme for the site lives in a git submodule.
However, it looks like they are working on that actively and with great care.
Second, I missed git-format-patch.
This one is more minor, because I can always just fall back to git to do so.
Even in just a few days, I’ve wanted to serialize a patch to a file (i.e., git format-patch) or apply some patch I have sitting around with git am.
One example is a patch file I keep around that customizes an environment for debugging purposes.
As a jj-native approach, I’ve been keeping a change separate that I can rebase into my patch series and move out later if needed.
Then, there are a handful of more rarely used things that I have not needed yet, and consequently have not figured out equivalents for.
For example, I’m not sure about an equivalent for view release notes via tags.
With git, I used an alias like
[alias]
tlog = tag --sort=-v:refname -l --format='%(color:red)%(refname:strip=2)%(color:reset) - %(color:yellow)%(contents:subject)%(color:reset) by %(taggername) on %(taggerdate:human)\n\n%(contents:body)'
But, then it seems right now jj only supports lightweight tags, not annotated git tags.
I’m not sure an equivalent for git shortlog.
Most likely would need to build some template.
I don’t have some jj equivalent of git-stats.
But again, it’s git-compatible, so I can just keep using git-stats.
I’m sure there will be a long tail of things like this.
Things I like more about jj
Steve and others have described jj as “both simpler and easier than git, but at the same time, it is more powerful.”
I’m not sure I fully agree.
In some ways, it has better defaults (e.g., jj split, jj absorb), but in other ways, it is complex (e.g., revset language).
That said, I do see how it is more powerful.
You cannot exactly your git mental model to directly onto jj’s.
That said, one of the main things I enjoy at $WORK is that for a repository, git’s default “view” (for lack of a better word) is a branch, whereas jj’s default view is much higher level.
I find jj’s approach more intuitive: it’s always pretty trivial to see what branches/PRs I have ongoing at a glance (i.e., with jj log -r "@ | ancestors(trunk()..(visible_heads() & mine()), 2) | trunk()").
It also means you can easily do fun stuff like rebase all of your branches at the same time, and push them all simultaneously to your remote!
This is made even better by the fact that conflicts are first-class objects, meaning you can rebase all your branches, and deal with conflicts later.
In a similar vein: jj emphasizes commits or changes in a way I find pleasing.
Even though in git I have ways to do similar things, jj does indeed make them easier.
For example, splitting one commit into multiple is easier.
In git, you’d probably do something like
git rebase -i
# select the commit you want to split for editing
git reset HEAD~1 --mixed
# add the subset of changes you want in the first commit
git commit -c ORIG_HEAD
# tweak if necessary
git add -u
git commit
# write a commit message for the 2nd patch from scratch
In jj, jj split just does the more intuitive thing.
Instead, the same process looks more like
jj split -r {REVISION}
# select the subset of changes you want in the first commit
# automatically presented with the original commit message, tweak if necessary
# remaining changes automatically put in 2nd commit, presented with original commit message to tweak again
As another example, I’m a huge fan of git-absorb.
It makes folding follow-up changes into an appropriate earlier commit easy and effective.
However, it isn’t built into git.
Meanwhile, jj ships with jj absorb, which does the same thing.
I found that in git, I was already following a workflow close to the squash workflow recommended by jj’s creator, Martin.
Things I still find awkward
The main thing I still feel clumsy with is named bookmarks (similar to git branches).
Specifically, I find it hard to remember to set the bookmark when I make updates, and how to rebase them without looking up commands.
That said, it’s becoming more familiar over time.
My configs
Much like git, I’ve also found that jj’s defaults aren’t entirely sufficient for me.
I needed to make some tweaks.
You can see my up-to-date dotfiles here, but as a snapshot, these are changes I found to be particularly valuable right from the start.
#:schema https://jj-vcs.github.io/jj/latest/config-schema.json
[user]
name = "Luke Hsiao"
email = "{{ .email }}"
[git]
auto-local-bookmark = true
# Prevent pushing work in progress or anything explicitly labeled "private"
private-commits = "description(glob:'wip:*') | description(glob:'private:*')"
[ui]
editor = "hx"
pager=["delta", "--pager", "less -FRX"] # Keeps output from the terminal after closing the pager
diff-formatter = ":git" # required by `delta`
# These options allow for `jj diff` and `jj show` to clear the output from the terminal after closing the pager.
[[--scope]]
--when.commands = ["diff", "show"]
[--scope.ui]
pager = "delta"
[template-aliases]
commit_template = '''"JJ: <type>(<scope>): (If applied, this commit will...) <subject> (Max 50 char)
JJ: |<---- Try to limit to a max of 50 char ---->|
JJ: Explain why this change is being made
JJ: |<----- Try to limit each line to a maximum of 72 characters ----->|
JJ: Provide links or keys to any relevant tickets, articles or other resources
JJ: Example: GitHub issue #23, BREAKING CHANGE: <description>, Ref: 5119ae
JJ: --- COMMIT END ---
JJ: Type can be
JJ: build: changes that affect the build system or external dependencies
JJ: chore: updating grunt tasks; no production code change
JJ: ci: changes to our ci configuration files and scripts
JJ: docs: changes to documentation
JJ: feat: new feature, correlates with MINOR
JJ: fix: bug fix, correlates with PATCH
JJ: perf: a code change that improves performance
JJ: refactor: refactoring production code; neither fixes a bug nor adds a feature
JJ: revert: for reverts, revert: <old subject>; state reverted hash in body
JJ: style: formatting, missing semicolons, etc; no change in code behavior
JJ: test: adding or refactoring tests; no production code change
JJ:
JJ: Scope refers to the file, directory, or system that is being modified.
JJ:
JJ: Follow the type(scope) with an '!' to indicate a breaking change.
JJ: Example: refactor(api)!: remove the accident-prone delete_all button
JJ: --------------------
JJ: Remember to
JJ: Use the imperative mood in the subject line
JJ: Do not end the subject line with a period
JJ: Separate subject from body with a blank line
JJ: Use the body to explain what and why vs. how
JJ: Can use multiple lines with '-' for bullet points in body
JJ: BREAKING CHANGE: to correlate with MAJOR
JJ:
JJ: See: https://www.conventionalcommits.org/en/v1.0.0/)"
'''
[templates]
git_push_bookmark = '"lwh/" ++ change_id.short()'
draft_commit_description ='''
concat(
coalesce(description, commit_template, "\n"),
surround(
"\nJJ: This commit contains the following changes:\n", "",
indent("JJ: ", diff.stat(72)),
),
"\nJJ: ignore-rest\n",
diff.git(),
)
'''
[aliases]
cl = ["log", "-r", "trunk()..@-", "--reversed", "-T", "description ++ '\n---\n\n'", "--no-graph", "--no-pager"]
l = ["log", "-r", "@ | ancestors(trunk()..(visible_heads() & mine()), 2) | trunk()"]
la = ["log", "-r", "all()"]
pba = ["git", "push", "-b", "glob:lwh/*"]
rba = ["rebase", "-s", "all:roots(mutable())", "-d", "trunk()"]
tug = ["bookmark", "move", "--from", "heads(::@- & bookmarks())", "--to", "@-"]
First, I strongly prefer delta over the default viewer.
jj also makes it easy to view with another tool with --tool.
For example, I also like using difft.
I added a config from this discussion so jj log doesn’t clear the screen on quit.
Second, I like having my conventional commit template automatically populated on jj describe.
I also have the diff included, which mimics the config I had with git using
[commit]
template = ~/.config/git/commit-template
verbose = true
Third, I changed the default git push bookmark prefix to lwh/.
Finally, I added a bunch of aliases I use frequently.
I use jj cl to copy-paste commit descriptions into PR descriptions.
I use jj l to view all the logs most relevant to me.
This was discovered courtesy of Will Richardson.
I use jj la to view all logs.
I use jj pba and jj rba to push all my bookmarks, and rebase all my changes on main, respectively.
Finally, I use jj tug to move my bookmark to the latest change (I found this one thanks to Shaddy).
Thoughts so far
I actually find jj quite fun!
Again, I do not think it “solved” any pain points I had with git.
I was already a very happy user.
Ultimately, jj’s interface and mental model do feel refreshing enough that I’m likely to continue using it.