TIL: Configuring jj fix with Ruff
I wrote a recent blog post about hacking together a crude version of Git hooks in the Jujutsu VCS. While this is a helpful last resort to avoid pushing unlinted, unformatted code to a remote, Jujutsu has a much more elegant built-in tool to solve the problem of messy local commits: jj fix.
Once it’s configured, the fix command runs your formatter/linter over all of your writable1 commits, applying fixes retroactively and automatically rebasing without introducing merge conflicts. For example, say you accidentally committed some unformatted code in oytprvlu and then made changes on top of that in zpztzrwl:
$ jj log
...
○ zpztzrwl [email protected] 2025-07-13 09:37:06 git_head() eb19b744
│ a good commit
○ oytprvlu [email protected] 2025-07-13 09:36:04 51eaee3d
│ unformatted commit
...
Running jj fix automatically formats the bad commit and applies that fix through to the working copy.
$ jj fix
Fixed 3 commits of 4 checked.
Working copy (@) now at: klmvywln e738b3ac (no description set)
Parent commit (@-) : zpztzrwl 38335008 a good commit
Added 0 files, modified 1 files, removed 0 files
Of course, jj doesn’t know how to fix things unless you tell it how, which is where tool configuration comes in.
Configuring Ruff
At the project level, tools are configured in .jj/repo/config.toml, which you can open automatically with jj config edit --repo. Each tool registers a command, which jj fix will pass changed files (matched by pattern) into via stdin. On a zero exit status, the file contents are replaced with the tool output emitted on stdout.
Here’s how I configure linting and formatting with Ruff in Python projects:
[fix.tools.1-ruff-lint]
command = ["uv", "run", "ruff", "check", "--fix", "--preview", "--quiet", "--stdin-filename=$path", "-"]
patterns = ["glob:'**/*.py'"]
[fix.tools.2-ruff-format]
command = ["uv", "run", "ruff", "format", "--stdin-filename=$path", "-"]
patterns = ["glob:'**/*.py'"]
This sets up two separate tools for linting and formatting .py files, prefixed with a number to indicate their execution order2. Both are invoked using uv run, but match that to your virtual environment. Here’s a breakdown of the other arguments that I pass to ruff check and ruff format:
--fix: Apply lint fixes. Without this option,jj fixwould still run linting but abort on errors.--preview: Enables some additional rules and fixes.--quiet: Suppresses linter summary diagnostics that are mostly redundant with those reported byjj. If there are unfixable errors,ruffwill still report those.--stdin-filename=$path: Passes the name of the file being checked to Ruff so that it can accurately report unfixable errors.-: This is the critical option that tells Ruff to checkstdinrather than a file.
Other tools
Python type checking
Ty doesn’t currently support stdin and Mypy doesn’t plan to. While including type checking as a validation step would be nice, it’s complicated by the fact that full analysis requires access to the entire project, not just individual files.
OCaml
This seems to work with ocamlformat:
[fix.tools.ocamlformat]
command = ["ocamlformat", "--name=$path", "-"]
patterns = ["glob:'**/*.ml'", "glob:'**/*.mli'"]
Passing --name is critical here so that ocamlformat knows whether it’s working on an implementation or interface file.
Others
The jj docs provide a few more example configurations for clang-format and rustfmt.
In general, the key to configuring a tool is finding the parameters that 1) accept stdin, 2) identify filenames on stdin, and 3) emit to stdout if that’s non-default.
Once commits are pushed remotely,
jjtreats them as read-only by default to avoid rewriting shared history. Runningjj fix --ignore-immutablewill allow you to format those commits, if you don’t mind force-pushing changes. ↩︎Per the
ruff-pre-commitREADME, linting with--fixshould be run before formatting. ↩︎