Pre-push hook with Jujutsu
I’ve been a big fan of Jujutsu, a self-described “git-compatible VCS that is both simple and powerful”. In a world dominated by git and GitHub (when it’s not down…), Jujutsu provides an alternative frontend to git which, in my opinion, makes working with complex git workflows a lot nicer. Particularly, rebase operations are just so much easier when revisions are your primary unit and conflicts are not blocking.
Since Jujutsu wraps around git, it’s very easy to use it without anyone else having to know or care about it. My coworkers all use git, but I can still use jj on the same repo. But this choice doesn’t come without pain. At this point in time, some git components are not yet supported by Jujutsu, such as git LFS, git submodules and the topic of today’s post: git’s hook system.
Nowadays, it’s quite common for a collaborative codebase to impose its linting and testing harness not only on the remote via the CI, but also locally at the commit layer using a pre-commit hook. The most famous tool for this is pre-commit (and its Rust cousin prek), which let you declare your linters and formatters and take care of everything else for you.
To be honest, I never really liked pre-commit. It’s a good piece of software, but I just don’t like having a separate specification for my dev tooling version outside of the package manifest. I can’t tell you how many times I’ve had some headache because the pinned version of ruff or pyright in .pre-commit-config.yaml was different than the one recorded inside pyproject.toml.
In my own projects, I usually like to use a task runner (most of the time make, but sometimes I use just or go-task) to handle these operations. My typical Makefile for a Python project would look like this
.PHONY: fmt lint test ci
fmt: @echo "Running formatters..." @uvx ruff format @uvx ruff check --fix
lint: @echo "Running linters..." @uvx ruff format --check @uvx ruff check @uv run pyrefly check
test: @echo "Running tests..." @uv run pytest
ci: lint testOf course, if I used pre-commit, I could just use pre-commit run for make ci, etc., but to me that’s the wrong generalization. I could also write a make-dependent script and have pre-commit run that, but then why not just place that script in .git/hooks myself and remove the middle man? For example, this is a simple pre-commit hook
#!/usr/bin/env bashmake ciNow, the second difficulty here is that Jujutsu doesn’t play nice with pre-commit hooks (because Jujutsu revisions don’t map 1:1 with git commits). An alternative is to use pre-push hooks!1 This whole post would be over if Jujutsu respected git’s hooks, alas Jujutsu ignores them completely. The workaround here is to create a Jujutsu alias
[aliases]p = ["util", "exec", "--", "pre-push", "run"]so that jj p calls a wrapper script which executes the hook (if it exists) and aborts the push if the hook fails
#!/usr/bin/env bash
hook="$(git rev-parse --git-path hooks)/pre-push"
if [ -f "$hook" ]; then "$hook" status=$?
if [ "$status" -ne 0 ]; then echo "pre-push hook failed with exit code $status" >&2 exit "$status" fifi
jj git push "$@"That’s it! Now if your team uses a Makefile, a just command or pre-commit, all you need to do is create the appropriate hook in .git/hooks/pre-push and it will automatically be run by both git and Jujutsu. I now leave you with my full script to set and execute the git hook so that you don’t have to do anything manually
#!/usr/bin/env bashset -u
usage() { cat <<EOFUsage: pre-push set <command> Set the pre-push hook command pre-push run [args...] Run the hook, then execute jj git push pre-push --help Show this help
Examples: pre-push set "make ci" pre-push run pre-push run --bookmark mainEOF}
hook="$(git rev-parse --git-path hooks)/pre-push"
set_hook() { if [ "$#" -ne 1 ]; then echo "error: set requires exactly one command" >&2 usage >&2 exit 2 fi
mkdir -p "$(dirname "$hook")"
cat > "$hook" <<EOF#!/usr/bin/env bash$1EOF
chmod +x "$hook" echo "Created pre-push hook: $hook"}
run() { if [ -f "$hook" ]; then "$hook" status=$?
if [ "$status" -ne 0 ]; then echo "pre-push hook failed with exit code $status" >&2 exit "$status" fi fi
jj git push "$@"}
case "${1:-}" in set) shift set_hook "$@" ;; run) shift run "$@" ;; --help|-h) usage ;; "") usage >&2 exit 2 ;; *) echo "error: unknown command: $1" >&2 usage >&2 exit 2 ;;esacFootnotes
-
I prefer pre-push hooks anyway because the natural point in my workflow where I worry if my code is formatted properly and passes all tests (which can be slow) is before pushing my work, not at every commit. If an intermediate change needs to be fixed, I can always use
jj absorb. ↩