Back

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

Makefile
.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 test

Of 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

.git/hooks/pre-commit
#!/usr/bin/env bash
make ci

Now, 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

$HOME/.config/jj/config.toml
[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

$HOME/.local/bin/pre-push
#!/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"
    fi
fi

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

$HOME/.local/bin/pre-push
#!/usr/bin/env bash
set -u

usage() {
    cat <<EOF
Usage:
  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 main
EOF
}

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
$1
EOF

    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
        ;;
esac

  1. 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. ↩︎