jj-hooks 0.3.4

Run pre-commit / lefthook / hk hooks against jj bookmark pushes
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# jj-hooks

Run [pre-commit](https://pre-commit.com/), [prek](https://github.com/j178/prek),
[lefthook](https://github.com/evilmartians/lefthook), or
[hk](https://hk.jdx.dev) hooks against [jj](https://jj-vcs.github.io) 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`:

1. Asks jj which bookmarks the push would update on the remote.
2. For each bookmark being added or moved, creates an ephemeral detached git
   worktree at the target commit and runs the configured hook backend there.
3. 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 squash`
   the fixes into your target or inspect them with `jj show`.
4. 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]https://github.com/acarapetis/jj-pre-push — the Python tool
  that originally inspired this. `jj-hooks` adopts 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)

```bash
cargo binstall jj-hooks
```

This pulls a prebuilt binary from the GitHub Releases page — no compile step.

### Via Homebrew tap

```bash
brew tap mattwilkinsonn/zireael https://github.com/mattwilkinsonn/zireael
brew install mattwilkinsonn/zireael/jj-hooks
```

### From source

```bash
jj git clone https://github.com/mattwilkinsonn/jj-hooks
cargo install --path .
```

After install run the interactive setup (optional):

```bash
jj-hp init
```

This prompts to:

- Install a user-level `jj push` alias that delegates to `jj-hp push`.
- Enable `jj-hooks.advance-bookmarks` so the local bookmark automatically moves
  to the fixup commit when hooks autofix something.
- Install jjui actions/bindings so `jj-hp push` is reachable from inside jjui.

All three can be reconfigured by running `jj-hp init` again.

## jjui integration

`jj-hp init` (and the standalone TOML below) installs two
[jjui](https://github.com/idursun/jjui) actions and keybindings so
`jj-hp push` is one keypress away from inside the TUI.

| Action | Default key | What it does |
| --- | --- | --- |
| `jj-hp-push-selected` | `x p` | Push the bookmark at the focused commit (`jj-hp push -r context.commit_id()`) |
| `jj-hp-push` | `x P` | Push every local bookmark (`jj-hp push`) |

Lowercase is the more-frequent case (selected). The 2026-05 swap moved
the keys to this layout; `jj-hp init` migrates pre-swap configs in
place — user-customized key sequences are left alone.

If you'd rather hand-edit `~/.config/jjui/config.toml` instead of
running `jj-hp init`, append:

```toml
[[actions]]
name = "jj-hp-push-selected"
lua = """
  jj_async("util", "exec", "--", "jj-hp", "push", "-r", context.commit_id())
  revisions.refresh()
"""

[[actions]]
name = "jj-hp-push"
lua = """
  jj_async("util", "exec", "--", "jj-hp", "push")
  revisions.refresh()
"""

[[bindings]]
action = "jj-hp-push-selected"
seq = ["x", "p"]
scope = "revisions"
desc = "jj-hp push selected bookmark(s)"

[[bindings]]
action = "jj-hp-push"
seq = ["x", "P"]
scope = "revisions"
desc = "jj-hp push"
```

The `revisions.refresh()` after each `jj_async` repaints jjui's
revisions pane so the bookmark moves are visible immediately.

## 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.

```bash
# zsh: add to ~/.zshrc
eval "$(jj-hp completions zsh)"

# bash: add to ~/.bashrc
eval "$(jj-hp completions bash)"

# fish: write to ~/.config/fish/completions/jj-hp.fish
jj-hp completions fish > ~/.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-hp` directly. The `jj push` alias
> (installed by `jj-hp init`) runs through jj's own completion script, which
> doesn't expand user aliases — so `jj push -b <TAB>` won't complete bookmark
> names. Use `jj-hp push -b <TAB>` instead. This is a limitation of jj's
> completion script, not jj-hooks.

## Usage

```text
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]#setup-steps). |
| positional `REVSET` | `@` | Revset to check |

## Runner autodetection

`jj-hooks` probes the workspace root for these files, in order:

1. `hk.pkl``hk`
2. `lefthook.yml` / `lefthook.yaml` / `.lefthook.yml` / `.lefthook.yaml``lefthook`
3. `.pre-commit-config.yaml` / `.pre-commit-config.yml``pre-commit`
4. `prek.toml` / `.prek.toml``prek` (prek's native TOML config)

If multiple match, `jj-hooks` errors out and asks for `--runner` — except for
the pre-commit / prek pair, which collapse to `prek` since `prek` reads both
formats. When only `.pre-commit-config.yaml` matches and `prek` is resolvable
(see [Runner binary resolution](#runner-binary-resolution) below), `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` and
`jj-hp run` prints `no hook-runner config in target commit; skipping hooks`.

## Runner binary resolution

Once a runner is picked, `jj-hp` looks for the actual binary in the
following order. First hit wins; if everything misses, you get a
structured error naming the missing binary instead of a libc-level
`No such file or directory`.

1. **`jj-hooks.runner-bin.<runner>` config.** Explicit override. Set in
   your jj user, repo, or workspace config:

   ```toml
   [jj-hooks.runner-bin]
   prek = ".venv/bin/prek"                  # scalar: absolute or relative-to-workspace
   pre-commit = ["uv", "run", "--", "pre-commit"]   # array: wrapper + args
   ```

   Relative paths in the scalar form are resolved against the workspace
   root. The array form is taken verbatim and is the right shape for
   wrappers like `uv run`, `poetry run`, `nix shell -c`, etc.

2. **`.git/hooks/<stage>` shim path.** When you've run `prek install`
   or `pre-commit install`, the shim bakes the absolute path to the
   resolved binary into the hook file. `jj-hp` parses it out and uses
   the same path, so `jj-hp push` invokes the same runner that `git
   commit` / `git push` would. The two formats differ:
   - prek bakes `PREK="…/.venv/bin/prek"` and is invoked directly.
   - pre-commit bakes `INSTALL_PYTHON=…/.venv/bin/python` and is
     invoked as `python -mpre_commit` (i.e. the Python interpreter is
     the resolved binary; pre-commit is invoked as a module).
   Both shapes are handled.

3. **`uv run` wrapping.** When `workspace_root/uv.lock` exists *and*
   `uv` is on `$PATH`, prek / pre-commit invocations are prefixed with
   `uv run --project <workspace_root> --`. uv resolves the project's
   venv automatically; you don't have to activate anything. The
   `--project` flag points uv at your actual workspace rather than
   the ephemeral worktree jj-hp runs the hook in (since `.venv` is
   typically gitignored and wouldn't exist in the worktree). Only
   applies to prek / pre-commit (lefthook / hk aren't Python tools).

4. **`$PATH`.** The classic fallback — bare program name found via the
   shell's PATH walk.

If you see `hook runner ‹bin› is not on $PATH`, none of the four layers
matched. Either install the runner globally (`brew install prek`,
`pipx install prek`, `uv tool install prek`), activate your venv
before invoking `jj-hp`, or set `jj-hooks.runner-bin.<runner>`.

## 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:

```text
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:

```bash
jj log -r 0123abcd      # inspect the fixup
jj squash --from 0123abcd --into main      # fold the fixes into main
```

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:

```bash
# Single step: `bun install` before every hook run.
jj config set --repo 'jj-hooks.setup' \
  '[{ name = "install deps", run = ["bun", "install", "--frozen-lockfile"] }]'

# Verify what landed:
jj config get jj-hooks.setup

# Remove it later:
jj config unset --repo jj-hooks.setup
```

Multi-step setup is the same call — `jj config set` takes the whole value
as one TOML expression. Wrap multiple inline tables in `[ … ]`:

```bash
jj config set --repo 'jj-hooks.setup' \
  '[
    { name = "install deps", run = ["bun", "install", "--frozen-lockfile"] },
    { name = "codegen", run = ["bun", "run", "prepare"] },
  ]'
```

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:

```bash
$EDITOR "$(jj config path --repo)"
```

```toml
# .jj/repo/config.toml
[[jj-hooks.setup]]
name = "install deps"
run = ["bun", "install", "--frozen-lockfile"]

[[jj-hooks.setup]]
name = "codegen"
run = ["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:

```toml
# 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.
[[jj-hooks.setup]]
name = "share node_modules"
run = ["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]#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](https://github.com/acarapetis/jj-pre-push) or just prefer typing
`jj push`, `jj-hp init` can wire up an alias for you:

```toml
# Added to ~/.config/jj/config.toml by `jj-hp init`
[aliases]
push = ["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

```bash
just install-deps   # install pre-commit, prek, lefthook, hk, markdownlint-cli2, actionlint
just test           # check-deps + cargo nextest
just ci             # fmt-check + clippy + test
```

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.