Home / Posts / Configuring Jujutsu View Raw
23/05 — 2025
145.85 cm   9.5 min

Configuring Jujutsu

There are a lot of reasons to use jujutsu, but this post is not about that. I have this terrible habit of turning every knob on a tool before I even fully absorb how it works; and boy does jj have a lot of knobs.

Templates

jj let you tweak nearly every single character of its output. In fact, jj includes a full-blown templating language to do so. Lets start from the basics:

Format all timestamps in relative fashion, like “5 hours ago”:

[template-aliases]
"format_timestamp(timestamp)" = "timestamp.ago()";

The default nodes in the log graph are a bit large for my taste, we can modify the log_node template to fix that:

[templates]
log_node = '''
  label("node",
    coalesce(
      if(!self, label("elided", "~")),
      if(current_working_copy, label("working_copy", "@")),
      if(conflict, label("conflict", "×")),
      if(immutable, label("immutable", "*")),
      label("normal", "·")
    )
  )
'''

This uses smaller iconography in jj log (these icons are also more widely available in fonts, in my experience):

@  wuuownsw me@oppi.li 21 minutes ago 30d1bd12
│  (no description set)
·  plmznxvy me@oppi.li 5 hours ago push-plmznxvyqrqw git_head() 051c142e
│  appview: pulls: bump sourceRev for stacks without causing resubmits
│ *  towvwqxk x@icyphox.sh 4 hours ago master a588f625
│ │  appview: pages/markup: don't double camo in post process
│ *  ryzqnyvs me@oppi.li 4 hours ago ea4b520a
│ │  appview: fix stack merging
│ *  kqvutzxr me@oppi.li 4 hours ago ff73ca23
├─╯  appview: rework RepoLanguages
*  vwpqwmms jeynesbrook@gmail.com 8 hours ago d759587b
│  appview: repo: inject language percentage into repo index
~

Much nicer! And speaking of log, I find it hard to parse the output when every change occupies two lines instead of just one line, we can remedy that quickly; in fact, jj has a builtin template for single-line log outputs:

λ jj log -T builtin_log_oneline
@  wuuownsw me@oppi.li 22 minutes ago 30d1bd12 (no description set)
·  plmznxvy me@oppi.li 5 hours ago push-plmznxvyqrqw git_head() 051c142e appview: pulls: bump sourceRev for stacks without causing resubmits
│ *  towvwqxk x@icyphox.sh 4 hours ago master a588f625 appview: pages/markup: don't double camo in post process
│ *  ryzqnyvs me@oppi.li 4 hours ago ea4b520a appview: fix stack merging
│ *  kqvutzxr me@oppi.li 4 hours ago ff73ca23 appview: rework RepoLanguages
├─╯
*  vwpqwmms jeynesbrook 8 hours ago d759587b appview: repo: inject language percentage into repo index
│
~

Bit too long! I personally do not always need author and time information when quickly scrolling through the log, so I created my own template based on builtin_log_oneline:

[templates]
log = '''
  if(root,
    format_root_commit(self),
    label(if(current_working_copy, "working_copy"),
      concat(
        separate(" ",
          format_short_change_id_with_hidden_and_divergent_info(self),
          if(empty, label("empty", "(empty)")),
          if(description,
            description.first_line(),
            label(if(empty, "empty"), description_placeholder),
          ),
          bookmarks,
          tags,
          working_copies,
          if(git_head, label("git_head", "HEAD")),
          if(conflict, label("conflict", "conflict")),
          if(config("ui.show-cryptographic-signatures").as_boolean(),
            format_short_cryptographic_signature(signature)),
        ) ++ "\n",
      ),
    )
  )
'''

Which produces:

@  wuuownsw (no description set)
·  plmznxvy appview: pulls: bump sourceRev for stacks without causing resubmits push-plmznxvyqrqw HEAD
│ *  towvwqxk appview: pages/markup: don't double camo in post process master
│ *  ryzqnyvs appview: fix stack merging
│ *  kqvutzxr appview: rework RepoLanguages
├─╯
*  vwpqwmms appview: repo: inject language percentage into repo index
│
~

Sweet! To get a more detailed log, you can always use a different template for the output: jj log -T builtin_log_detailed.

With git, I set commit.verbose to true, this lets me view the diff when composing a commit messaege in my $EDITOR:

λ git commit
# no message passed, opens $EDITOR to compose message

# -- inside vim --

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch trunk
# Changes to be committed:
#   new file:   scripts/handle.js
#
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
diff --git a/scripts/handle.js b/scripts/handle.js
new file mode 100644
index 0000000..d8a07f3
--- /dev/null
+++ b/scripts/handle.js
@@ -0,0 +1,104 @@
+// Run using node handle.js
+// Install axios using npm install axios
+// nodejs v18+
+const axios = require("axios");
.
.
.

The equivalent in jj requires modifying the draft_commit_description template:

[templates]
draft_commit_description ='''
    concat(
      coalesce(description, default_commit_description, "\n"),
      surround(
        "\nJJ: This commit contains the following changes:\n", "",
        indent("JJ:     ", diff.stat(72)),
      ),
      "\nJJ: ignore-rest\n",
      diff.git(),
    )
'''

Revsets

As a not-so-power-user, I presently only use revsets to improve my jj log experience. In the context of jj log, giving it a revset means “here is a bag of revisions, graph them for me neatly”.

I find myself using the “rebase” flow quite often:

  • I hack on a stack of changes
  • trunk is updated by my fellow collaborators
  • I rebase my stack using jj rebase -s <mine> -d <trunk>

And I scrub through the log output to roughly figure out how much work it would be to rebase, what I need for this is:

  • the changes added to trunk since I last diverged from it
  • the changes add to my stack since I last diverged from trunk
       X--Y--Z   my stack
      /
  F--A--B--C--D  trunk

If you want jj log to print this (and by “this”, I mean, the graph above, exactly as presented), you have to supply it a revset argument that grabs X, Y, Z, A, B, C, D and F. Some examples of revsets are:

@                        # the rev marking the working copy
x                        # the rev identified by shorthand `x`
x | y                    # the set of two revs, [x, y]
@ | trunk()              # the set of two revs, [@, trunk()]
..                       # everything in this repository
all()                    # also everything in this repository
fork_point(@ | trunk())  # the rev where @ and trunk() forked off

And the revset that captures “ahead-behind” style output is:

trunk()..@ | @..trunk() | trunk() | @:: | fork_point(trunk() | @)

Which includes:

  • trunk()..@: the new changes that my collaborators have added
  • @..trunk(): the new changes that I have added
  • trunk(): the trunk rev itself
  • @::: all descendants of @
  • fork_point(trunk() | @): the rev from which the two streams of work diverged

Rougly, when working on a stack, this is what I can see by supplying the above revset expression (simplified output):

λ jj log -r 'trunk()..@ | @..trunk() | trunk() | @:: | fork_point(trunk() | @)'
@  xdihgmke              <-- me
·  vurstull
·  plmznxvy
·  ihefghyy
 *  towvwqxk trunk      <-- trunk
 *  ryzqnyvs
 *  kqvutzxr
├─╯
*  vwpqwmms              <-- fork-point

And sometimes, when I am editing older parts of my stack (@:: helps us grab xdihgmke and vurstull):

λ jj log -r 'trunk()..@ | @..trunk() | trunk() | @:: | fork_point(trunk() | @)'
·  xdihgmke
·  vurstull
@  plmznxvy              <-- me
·  ihefghyy
 *  towvwqxk trunk      <-- trunk
 *  ryzqnyvs
 *  kqvutzxr
├─╯
*  vwpqwmms              <-- fork-point

To rebase, I would run:

jj rebase -s ihefghyy -d towvwqxk

Aliases

I imagine that most git power users are already familiar with aliases. The only alias I see myself using often is:

[aliases]
tug = ["bookmark" "move" "--from" "heads(::@- & bookmarks())" "--to" "@-"];

In action:

# ugh my bookmark is way behind
λ jj log
@  xdihgmke
·  vurstull
·  cyzmakil
·  plmznxvy
·  ihefghyy some-bookmark

~

λ jj tug

# ready to push!
λ jj log
@  xdihgmke
·  vurstull some-bookmark*
·  cyzmakil
·  plmznxvy
·  ihefghyy

~

This alias was stolen from this wonderful gist by Austin Seipp.

Experimental features

I use one experimental feature, that is only available on some of the newer versions of jujutsu:

# built from master
λ jj version
jj 0.29.0-8c7ca30074767257d75e3842581b61e764d022cf
[git]
write-change-id-header = true

This writes an extra commit-header (not to be confused with commit-trailer, which goes directly in the commit body) with the jujutsu change-id, as seen by inspecting the commit object:

λ git cat-file commit 4edebe96159bf81c3be662d0a2b4c7b08a062968
tree a9b7e9e3eed8a22c35829bf586d7560ec8396124
parent edc0d2750d4848bc05cfd3255ee1ca916bea9156
author oppiliappan <me@oppi.li> 1747919102 +0100
committer oppiliappan <me@oppi.li> 1747919102 +0100
change-id skrrxvvxlpzrqzpxlxksvryrykpxkvon          <-- this

This allows code forges to extract change-ids from commits, and most crucially: allows tracking changes across force pushes. This enables the interdiff format of code-review. Stacking, commit-wise-review, and interdiff are all supported on tangled (you can read more here), if you submit a branch with this experimental feature enabled.

Misc

Couple of other quality of life additions:

[ui]
default-command = "status"
pager = "delta"

I also use nix and home-manager to configure jj, you can find my configuration here.

Hi.

I'm Akshay, programmer, pixel-artist & programming-language enthusiast.

I am currently building tangled.sh — a decentralized code-collaboration platform.

Reach out at oppili@libera.chat.

Home / Posts / Configuring Jujutsu View Raw