I’m really liking Jujutsu (a.k.a jj) for version control! If you’re not familiar, jj is a version control system that interoperates with git. It removes a lot of possible states git can get into, adds much more helpful oopsie recovery, and adds some useful abstractions.
I’m working on setting up my workflow to cover the functionality that I previously got from git hooks. I’m used to using one of my projects, git-format-staged, to automatically format code. Here’s an example Nix project config that does that.
(I don’t like to set my editor to format on save because that doesn’t go well with autosave. Thus hooks.)
I saw that there is discussion on using pre-commit with jj in this thread. That has spawned two projects, jj-pre-push and jj-hooks, that can both run pre-commit - but they run it before pushing to a git remote, not on at commit time, because running a hook on every commit change doesn’t work well with jj’s model.
I’m trying something different because I want formatting to run earlier than on push. And of course I want my setup to be automated with a Nix devShell!
I thought a natural point to “hook in” would be on bookmark moves.
In jj branches are anonymous, but there are named “bookmarks”.
Bookmarks don’t move automatically like git branch pointers - you move them manually.
When working with an upstream git repo you get bookmarks that are associated with remote
branches - they’re similar to local tracking branches in git.
When you run jj git push it updates remote branches to the commits the
matching bookmarks are on.
So I think of bookmarks as my plan for what I’m going to push.
The typical workflow is:
- do some work
- advance a bookmark to the latest commit I’m ready to push
jj git pushdoes basically whatgit pushdoes if you setpush.default = matching(or if you rungit push origin :)
Usually the latest commit in your anonymous branch in jj is the “working copy”
which you probably don’t want to push. So it’s common to advance the bookmark to
the most recent “finished” commit: the parent of the working copy. That commit is
referenced by the expression @-
All that to say, I’m trying out an alias that automatically advances the
“closest” bookmark to @-, and runs jj fix on all of the commits that are now
in the bookmark’s history that weren’t before. (This is a Home Manager config
snippet):
# Move closest bookmark to @-, and run jj fix;
programs.jujutsu.settings.aliases.tug = [
"util"
"exec"
"--"
"bash"
"-c"
''
set -euo pipefail
jj fix -s 'heads(::@- & bookmarks())..@- & mutable()' # fix revisions after bookmark, up to and including @-
jj bookmark move --from 'heads(::@- & bookmarks())' --to '@-'
''
"" # last string becomes $0 -- see jj docs
# TODO: accept an argument to override @- as the new bookmark target
];
The tug alias has been floating around. My addition is the jj fix command.
If I use this alias my changes get automatically formatted before I commit to
pushing them.
jj fix is intended specifically for automatic code formatting. Because jj is
especially good at editing history, the fix command is good at retroactively
formatting commits. It identifies changed files in each of the given commits,
and runs their content through formatters via stdio. It’s capable of formatting
only changed portions of files if you configure formatters to accept line ranges.
Understanding that script requires spending some time learning jj’s revset language. The very short version is:
jj fix -s <expression>runs configured fix tools on the specified commitsheads(::@- & bookmarks())is a fancy way of identifying the closest commit with a bookmark on the DAGheads(::@- & bookmarks())..@-selects commits after that bookmark, up through@-& mutable()filters out commits that have already been pushed tomain(in case you’re pushing a merge). Commits already in your upstream trunk are considered “immutable” by default
I’d prefer a native hook to be able to run commands on any jj command that moves a bookmark. Maybe someday!
The last step is configuring the formatters that jj fix runs. This is where
the real Nix configuration comes in!
Formatters are set up in the jj config file.
Like with git you have a global config, and also a repo-specific config.
Also like with git, jj has a command to programmatically set config values.
I set up this devShell to use that command to automatically set up the repo-specific config for my project:
# Set formatters for `jj fix` to use for this repo
{
perSystem = { lib, pkgs, ... }: {
devShells.jj-fix =
let
# A JSON array is also a valid TOML array, so builtins.toJSON gives us
# a value literal that `jj config set` will accept for `command` and
# `patterns`.
tomlArg = value: lib.escapeShellArg (builtins.toJSON value);
set-tool-config = tool-name: config: /* bash */ ''
jj config set --repo fix.tools.${tool-name}.command ${tomlArg config.command}
jj config set --repo fix.tools.${tool-name}.patterns ${tomlArg config.patterns}
'';
# This variable holds the actual configuration for my three formatters
fix-tools = {
nixfmt = {
command = [
(lib.getExe pkgs.nixfmt)
"--filename=$path"
];
patterns = [ "glob:'**/*.nix'" ];
};
prettier = {
command = [
(lib.getExe pkgs.prettier)
"--stdin-filepath=$path"
];
patterns = [
''
(glob:'**/*.[jt]s' | glob:'**/*.[jt]sx' | glob:'**/*.json' | glob:'**/*.css' | glob:'**/*.md')
~ glob:'.expo/types/**' ~ glob:'**/.sqlx/**'
''
];
};
rustfmt = {
command = [
(lib.getExe pkgs.rustfmt)
"--edition=2024"
"--emit=stdout"
];
patterns = [ "glob:'**/*.rs'" ];
};
};
in
pkgs.mkShell {
shellHook = builtins.concatStringsSep "\n" (lib.mapAttrsToList set-tool-config fix-tools);
};
};
}
# TODO: Does not automatically remove tools from jj config if they are removed from the devShell
That’s my first pass. At some point I might publish a flake-parts module that abstracts out the boilerplate to make this nicer to use.
In theory it’s sort of like using git-format-staged. Formatting runs less frequently than with a pre-commit hook. That might mean a higher chance of conflicts. But the jj fix docs make bold claims about never introducing conflicts. We’ll see. I’m still experimenting with this setup.
Another possible workflow is hooking into jj new to run fix on the commit
that just stopped being the working copy.
The main functionality that’s still missing for me is a replacement for the git
pre-commit hook that I had that runs cargo prepare sqlx on pre-commit.
That command needs to be able to read changed source files, and to output
separate files to add to version control. There’s an upcoming jj run command
that might do the
trick.
@hallettj Oh, I was not aware of that jj command, thank you !


