jj-hooks
Run pre-commit, prek, lefthook, or hk hooks against jj bookmark pushes — with full support for secondary jj workspaces.
Ships as two binaries: jj-hooks (canonical name) and jj-hp (shorter alias
that's easier to type and works with shell completion). Pick the one you like;
they're identical.
What it does
jj-hp push is a drop-in replacement for jj git push:
- Asks jj which bookmarks the push would update on the remote.
- For each bookmark being added or moved, creates an ephemeral detached git worktree at the target commit and runs the configured hook backend there.
- If hooks fail or modify files, the push is aborted. Modifications get
committed as a fixup commit whose hash is printed so you can
jj squashthe fixes into your target or inspect them withjj show. - If everything passes cleanly, executes the real
jj git push.
jj-hp run [REVSET] runs hooks against a revset without pushing — useful for
"lint this change before I move on" workflows.
Why a worktree?
Earlier jj + pre-commit integrations ran hooks in the user's working copy,
which doesn't work from a secondary workspace: the worktree is the secondary's
files but the git index lives in the primary's .git, so pre-commit's
".pre-commit-config.yaml is unstaged" check fires every time.
jj-hooks sidesteps this entirely by running every hook in a fresh
git worktree add --detach checkout of the target commit. The user's
working copy is never touched, and the same code path works in both
primary and secondary workspaces.
Prior art
- jj-pre-push — the Python tool
that originally inspired this.
jj-hooksadopts its bookmark-update parsing strategy and broadens the runner support. - https://www.aazuspan.dev/blog/automating-pre-push-checks-with-jujutsu/
- Discussion on https://github.com/jj-vcs/jj/issues/405
Installation
Via cargo binstall (recommended)
This pulls a prebuilt binary from the GitHub Releases page — no compile step.
Via Homebrew tap
From source
After install run the interactive setup (optional):
This prompts to:
- Install a user-level
jj pushalias that delegates tojj-hp push. - Enable
jj-hooks.advance-bookmarksso the local bookmark automatically moves to the fixup commit when hooks autofix something. - Install jjui actions/bindings so
jj-hp pushis reachable from inside jjui.
All three can be reconfigured by running jj-hp init again.
Shell completion
jj-hp completions <shell> emits a clap-generated completion script. The
script wires dynamic completers for --bookmark and --remote that shell
out to jj to enumerate live values.
# zsh: add to ~/.zshrc
# bash: add to ~/.bashrc
# fish: write to ~/.config/fish/completions/jj-hp.fish
After that, jj-hp push -b <TAB> will complete bookmark names from your repo
and jj-hp push --remote <TAB> will complete remote names.
Note: completion only works for
jj-hpdirectly. Thejj pushalias (installed byjj-hp init) runs through jj's own completion script, which doesn't expand user aliases — sojj push -b <TAB>won't complete bookmark names. Usejj-hp push -b <TAB>instead. This is a limitation of jj's completion script, not jj-hooks.
Usage
jj-hp push [-b BOOKMARK]... [--remote REMOTE] [other flags] [-- JJ_GIT_PUSH_ARGS...]
jj-hp run [--stage pre-commit|pre-push] [REVSET]
jj-hp init
jj-hp completions <bash|zsh|fish|powershell>
Global flags:
| Flag | Env | Default | Effect |
|---|---|---|---|
--runner <pre-commit|prek|lefthook|hk> |
JJ_HOOKS_RUNNER |
autodetect | Override runner selection |
--log-level <level> |
JJ_HOOKS_LOG |
warn |
tracing-subscriber filter |
push flags (mirrors jj git push):
| Flag | Default | Effect |
|---|---|---|
-b/--bookmark NAME |
— | Push only this bookmark; repeatable |
-r/--revision REVSET |
— | Push bookmarks pointing at these commits; repeatable |
-c/--change REVSET |
— | Push these commits by creating a bookmark; repeatable |
--remote NAME |
— | The remote to push to |
--all |
off | Push all bookmarks (including new ones) |
--tracked |
off | Push all tracked bookmarks |
--deleted |
off | Push all deleted bookmarks |
--allow-new |
off | Allow pushing new (untracked) bookmarks |
--stage <pre-commit|pre-push> |
pre-push |
Which hook stage to run |
--advance-bookmarks |
from config | Move local bookmarks to fixup commits on autofix |
--dry-run |
off | Forwarded to jj git push |
anything after -- |
— | Forwarded verbatim to jj git push |
run flags:
| Flag | Default | Effect |
|---|---|---|
--stage <pre-commit|pre-push> |
pre-commit |
Which hook stage to run |
--all-files |
off | Run every hook against every tracked file, ignoring the revset's diff range. Maps to each runner's own all-files mode (see Setup steps). |
positional REVSET |
@ |
Revset to check |
Runner autodetection
jj-hooks probes the workspace root for these files, in order:
hk.pkl→hklefthook.yml/lefthook.yaml/.lefthook.yml/.lefthook.yaml→lefthook.pre-commit-config.yaml/.pre-commit-config.yml→pre-commit
If multiple match, jj-hooks errors out and asks for --runner. prek is
never autodetected by file — it shares pre-commit's config file. Instead, if
the autodetected runner is pre-commit and prek is on $PATH, jj-hooks
silently uses prek (it's a faster drop-in). Override with --runner pre-commit
to force the slower path.
If no config matches, jj-hp push falls through to plain jj git push.
Fixup commits
When hooks modify files in the ephemeral worktree, jj-hooks stages them,
writes a tree, builds a commit with the bookmark's current target as parent,
and anchors that commit under refs/heads/jj-hooks-fixup/<bookmark> just
long enough for jj git import to pick it up. Then it deletes both the
temp jj bookmark and the underlying git ref — the commit itself stays
fully addressable by hash in jj's commit graph.
The output of a push that produced a fixup looks like this:
jj-hooks: Move forward main from abc12345 to def67890: hooks modified files (fixup commit 0123abcd...)
jj-hooks: aborting push
Copy the 0123abcd... and decide what to do with it:
With --advance-bookmarks (or jj-hooks.advance-bookmarks = true in config),
jj-hooks advances the local bookmark to the fixup commit automatically —
re-run jj-hp push to actually push the fixed version.
The push is always aborted when a fixup commit is created. Run jj-hp push
again after squashing/advancing.
Setup steps
git worktree add --detach checks out the tracked tree only — gitignored
content like node_modules/, .venv/, target/ is absent. Hooks that
depend on those resources (e.g. tsc, pytest, cargo nextest) fail
inside the ephemeral worktree with command not found or module not found.
Configure jj-hooks.setup to declare commands jj-hp runs inside the
worktree before the hook runner fires.
Quick start
The fastest way to add a setup step is jj config set --repo, which writes
the value into the repo's config without you having to find or open the
file:
# Single step: `bun install` before every hook run.
# Verify what landed:
# Remove it later:
Multi-step setup is the same call — jj config set takes the whole value
as one TOML expression. Wrap multiple inline tables in [ … ]:
For long / multi-step configs the file form is easier to read. jj config path --repo prints the repo config's path (creating it if missing); edit
that file directly:
# .jj/repo/config.toml
[[]]
= "install deps"
= ["bun", "install", "--frozen-lockfile"]
[[]]
= "codegen"
= ["bun", "run", "prepare"]
User-level (apply to every repo): swap --repo for --user on every
command, or edit ~/.config/jj/config.toml. Repo-level overrides user-level
when both define the same key.
Step shape
Each entry:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | no | Label used in failure messages. Falls back to run[0]. |
run |
array of strings | yes | argv list — exec'd directly, no shell. |
run is an argv list (not a shell string) so quoting rules can't bite. For
chained commands write ["bash", "-c", "foo && bar"] explicitly.
Steps run in declared order. A non-zero exit aborts the pipeline before the hook runner is invoked — there's no point grading a broken worktree.
JJ_HOOKS_WORKSPACE
Both setup steps and hook subprocesses see JJ_HOOKS_WORKSPACE in their
environment, pointing at the workspace jj-hp was invoked from (primary or
secondary). Use it to reach back into the invocation workspace's resources:
# Hardlink-copy node_modules from the invocation workspace instead of
# running a full install. Cheap on Linux (hardlinks are O(file count) metadata
# ops); falls through to `cp -a` on macOS where -al isn't supported by default.
[[]]
= "share node_modules"
= ["bash", "-c", "cp -al \"$JJ_HOOKS_WORKSPACE/node_modules\" . 2>/dev/null || cp -a \"$JJ_HOOKS_WORKSPACE/node_modules\" ."]
The retry-after-fixup pass (issue jj-hooks#11) re-creates the worktree, so setup steps run again on the retry — important when the hook's first run mutates state that the setup needs to restore.
Workspaces
jj-hooks resolves the primary git directory via
.jj/repo/store/git_target, following the .jj/repo pointer file in
secondary workspaces. All git plumbing (worktree creation, commit-tree,
update-ref) targets the primary .git, so commits and refs land in the
shared object database regardless of which workspace you ran from.
Configuration
All config keys live under jj-hooks.* in jj's user/repo config:
| Key | Type | Default | Notes |
|---|---|---|---|
jj-hooks.advance-bookmarks |
bool | false | Default for --advance-bookmarks |
jj-hooks.setup |
array of tables | empty | Pre-hook setup steps; see Setup steps |
--runner and --stage are command-line / env only — they belong with the
invocation, not the config.
Using the jj push alias (optional)
If you came from
jj-pre-push or just prefer typing
jj push, jj-hp init can wire up an alias for you:
# Added to ~/.config/jj/config.toml by `jj-hp init`
[]
= ["util", "exec", "--", "jj-hp", "push"]
After that, jj push works exactly like jj-hp push. The catch is that
shell completion only sees jj's own completion table, which doesn't expand
user-defined aliases — so jj push -b <TAB> won't complete bookmark names.
For that, fall back to jj-hp push -b <TAB>.
The recommended workflow is to use jj-hp directly. The alias exists for
muscle memory.
Development
The test suite includes integration tests that build real jj+git repos in tempdirs, install local pre-commit hooks, and run the full push pipeline — including the secondary-workspace path. Every supported runner (pre-commit, prek, lefthook, hk) has dedicated integration tests for pass/fail/autofix.
License
Apache-2.0.