Signals & Pixels

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:

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.


  1. Once commits are pushed remotely, jj treats them as read-only by default to avoid rewriting shared history. Running jj fix --ignore-immutable will allow you to format those commits, if you don’t mind force-pushing changes. ↩︎

  2. Per the ruff-pre-commit README, linting with --fix should be run before formatting. ↩︎

#Jj #Python #Til