Add a jj push wrapper that runs prek before pushing. (#7805)

`scripts/jj_push.sh` takes the same arguments as `jj git push`, runs
prek over the commits that push would send, and pushes only if they
pass. It learns what is being sent by running `jj git push --dry-run`
and reading back the plan, so `--bookmark`, `--change`, `--all` and the
rest work without reimplementing how they select commits.

Hooks that rewrite files need a commit to write into, so the checks run
with the working copy on top of the commit being pushed. When the
working copy is already an empty commit there, which is the common case,
it is used directly; otherwise one is created, and named in the error so
the fixes can be squashed.

`scripts/jj_prek.sh` gets two changes. It forwards its arguments to
`prek run`, so `jj_push.sh` can ask for a specific range, and it now
changes to the workspace root before running. It exports `GIT_DIR`,
which makes git treat the current directory as the work tree, so prek
could not find its configuration from a subdirectory.

`jj` does not expand aliases when completing arguments, so `jj push`
completed file names. `scripts/completions` has Bash, Zsh, and Fish
completions that give it the same completions as `jj git push`.

`docs/project/contribution_tools.md` documents the `push` alias, and a
`prek` alias for `jj_prek.sh`, with the other per-repository `jj`
configuration. Both are opt-in.

Assisted-by: Claude Code
This commit is contained in:
Chandler Carruth
2026-09-18 19:40:25 +00:00
committed by GitHub
parent dbf79d5229
commit 681b3b10c4
7 changed files with 311 additions and 2 deletions
+18
View File
@@ -333,6 +333,12 @@ jj config set --repo 'revset-aliases."trunk()"' 'trunk@upstream'
# Treat github.com/carbon-language/carbon-lang as immutable, but treat your fork # Treat github.com/carbon-language/carbon-lang as immutable, but treat your fork
# as mutable. # as mutable.
jj config set --repo 'revset-aliases."immutable_heads()"' 'remote_bookmarks(*, upstream)' jj config set --repo 'revset-aliases."immutable_heads()"' 'remote_bookmarks(*, upstream)'
# Run `prek` over the commits a push would send, and push only if they pass.
jj config set --repo aliases.push '["util", "exec", "--", "sh", "-c", "exec \"$(jj workspace root)/scripts/jj_push.sh\" \"$@\"", "jj push"]'
# Run `prek` over the changes between `trunk()` and `@`.
jj config set --repo aliases.prek '["util", "exec", "--", "sh", "-c", "exec \"$(jj workspace root)/scripts/jj_prek.sh\" \"$@\"", "jj prek"]'
``` ```
<!-- google-doc-style-resume --> <!-- google-doc-style-resume -->
@@ -341,6 +347,18 @@ The above assumes that you have configured the remote name `origin` to refer to
your fork and `upstream` to refer to `github.com/carbon-language/carbon-lang`, your fork and `upstream` to refer to `github.com/carbon-language/carbon-lang`,
and will need to be adjusted if you use different remote names. and will need to be adjusted if you use different remote names.
The `prek` alias runs [`scripts/jj_prek.sh`](/scripts/jj_prek.sh), which runs
`prek` against `@` from anywhere in the workspace, including a non-colocated
one. Arguments go to `prek run`, so `jj prek --all-files` checks everything.
The `push` alias runs [`scripts/jj_push.sh`](/scripts/jj_push.sh), which checks
the commits the push would send and leaves anything the hooks change in a commit
for you to squash. It takes the same arguments as `jj git push`, and `--dry-run`
still runs the checks. `jj` only knows the name of an alias, not what it expands
to, so it completes file names after `jj push`.
[`scripts/completions`](/scripts/completions/README.md) has Bash, Zsh, and Fish
completions for the alias.
#### AI assistants #### AI assistants
When using AI assistants and reviewing terminal commands, some commands which When using AI assistants and reviewing terminal commands, some commands which
+57
View File
@@ -0,0 +1,57 @@
<!--
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-->
# Shell completions for `jj push`
Completions for a `jj push` alias that runs
[`scripts/jj_push.sh`](/scripts/jj_push.sh). See
[the Jujutsu section of the contribution tools doc](/docs/project/contribution_tools.md#jujutsu-jj)
to set up the alias.
`jj` only knows the name of an alias, not what it expands to, so it completes
file names after `jj push`. Each file here rewrites `push` to `git push` in the
command line before passing it to `jj`, giving the alias the flags, bookmarks,
revsets, and remotes of `jj git push`.
Each file loads `jj`'s own completions itself, and needs `jj` on `PATH` when it
runs. Remove any other setup that loads `jj`'s completions.
Run the commands below from your Carbon checkout, so that
`jj workspace root` fills in its path.
## Bash
```sh
echo "source $(jj workspace root)/scripts/completions/jj_push.bash" >>~/.bashrc
```
If your distribution ships a `jj` file in
`/usr/share/bash-completion/completions`, Bash loads it on demand and it
overrides this. Symlink this file to
`~/.local/share/bash-completion/completions/jj` instead of sourcing it.
## Zsh
```sh
echo "source $(jj workspace root)/scripts/completions/jj_push.zsh" >>~/.zshrc
```
This has to come after `compinit` in `.zshrc`, so move the line if `compinit`
runs later in the file.
## Fish
Fish loads completions on demand, after running `config.fish`, so sourcing this
at startup doesn't work: what fish loads later is added on top. Install it as
the file fish loads for `jj`:
```sh
ln -s "$(jj workspace root)/scripts/completions/jj_push.fish" \
~/.config/fish/completions/jj.fish
```
`~/.config/fish/completions` is first in `$fish_complete_path`, so this
overrides any `jj.fish` from your distribution.
+41
View File
@@ -0,0 +1,41 @@
# Part of the Carbon Language project, under the Apache License v2.0 with LLVM
# Exceptions. See /LICENSE for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Bash completions for a `jj push` alias that runs `scripts/jj_push.sh`. See
# `scripts/completions/README.md` for how to install this.
#
# `jj` only knows the name of an alias, not what it expands to, so it completes
# file names after `jj push`. Rewriting `push` to `git push` in the command line
# before passing it to `jj` gets the completions of `jj git push`.
source <(COMPLETE=bash jj)
_carbon_jj_complete() {
# Shadow the two variables `jj`'s completion function reads. Bash scopes them
# dynamically, so it sees the rewrite below.
local -a COMP_WORDS=("${COMP_WORDS[@]}")
local COMP_CWORD=$COMP_CWORD
local i
# Only look before the cursor. A `push` at the cursor is still being typed.
for ((i = 1; i < COMP_CWORD; i++)); do
case "${COMP_WORDS[i]}" in
-*) ;;
push)
COMP_WORDS=("${COMP_WORDS[@]:0:i}" git push "${COMP_WORDS[@]:i+1}")
((COMP_CWORD++))
break
;;
*) break ;;
esac
done
_clap_complete_jj "$@"
}
if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
complete -o nospace -o bashdefault -o nosort -F _carbon_jj_complete jj
else
complete -o nospace -o bashdefault -F _carbon_jj_complete jj
fi
+37
View File
@@ -0,0 +1,37 @@
# Part of the Carbon Language project, under the Apache License v2.0 with LLVM
# Exceptions. See /LICENSE for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Fish completions for a `jj push` alias that runs `scripts/jj_push.sh`. See
# `scripts/completions/README.md` for how to install this.
#
# `jj` only knows the name of an alias, not what it expands to, so it completes
# file names after `jj push`. Rewriting `push` to `git push` in the command line
# before passing it to `jj` gets the completions of `jj git push`.
#
# This replaces the completions fish loads for `jj`, so it has to be installed
# with that file's name. Loading both leaves `jj`'s file-name completion
# registered.
function __jj_completion_tokens --description 'Command line tokens, with the `push` alias expanded'
# `--cut-at-cursor` drops the token being completed, so any `push` here is
# a complete word.
set -l tokens (commandline --current-process --tokenize --cut-at-cursor)
for i in (seq 2 (count $tokens))
switch $tokens[$i]
case '-*'
continue
case push
printf '%s\n' $tokens[1..(math $i - 1)] git push \
$tokens[(math $i + 1)..-1]
return
case '*'
break
end
end
printf '%s\n' $tokens
end
complete -e -c jj
complete --keep-order --exclusive --command jj \
--arguments "(COMPLETE=fish jj -- (__jj_completion_tokens) (commandline --current-token))"
+33
View File
@@ -0,0 +1,33 @@
# Part of the Carbon Language project, under the Apache License v2.0 with LLVM
# Exceptions. See /LICENSE for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Zsh completions for a `jj push` alias that runs `scripts/jj_push.sh`. See
# `scripts/completions/README.md` for how to install this.
#
# `jj` only knows the name of an alias, not what it expands to, so it completes
# file names after `jj push`. Rewriting `push` to `git push` in the command line
# before passing it to `jj` gets the completions of `jj git push`.
source <(COMPLETE=zsh jj)
_carbon_jj_complete() {
local i
# Only look before the cursor. A `push` at the cursor is still being typed.
for ((i = 2; i < CURRENT; i++)); do
case ${words[i]} in
-*) ;;
push)
words=(${words[1, i - 1]} git push ${words[i + 1, -1]})
((CURRENT++))
break
;;
*) break ;;
esac
done
_clap_dynamic_completer_jj "$@"
}
compdef _carbon_jj_complete jj
+13 -2
View File
@@ -15,6 +15,12 @@ set -eu
# both check the wrong thing and fail to write back their fixes. # both check the wrong thing and fail to write back their fixes.
HEAD="$(jj show --no-patch -r @ --template 'commit_id')" HEAD="$(jj show --no-patch -r @ --template 'commit_id')"
# Run from the workspace root. Setting `GIT_DIR` makes git treat the current
# directory as the work tree, and prek looks there for its configuration, so
# running from a subdirectory would find neither. Hooks also expect paths
# relative to the root.
cd "$(jj workspace root --ignore-working-copy)"
# Find the .git directory. The working copy was snapshotted above, so this # Find the .git directory. The working copy was snapshotted above, so this
# doesn't need to do so again. # doesn't need to do so again.
export GIT_DIR="$(jj git root --ignore-working-copy)" export GIT_DIR="$(jj git root --ignore-working-copy)"
@@ -24,5 +30,10 @@ export GIT_INDEX_FILE="$(mktemp)"
trap 'rm -f "$GIT_INDEX_FILE"' EXIT trap 'rm -f "$GIT_INDEX_FILE"' EXIT
git read-tree "$HEAD" git read-tree "$HEAD"
# Run prek with the `.git` directory and index we built earlier. # Run prek with the `.git` directory and index we built earlier. Arguments
prek run --from-ref trunk --to-ref "$HEAD" # select what gets checked; with none, check everything between `trunk` and `@`.
if (($# > 0)); then
prek run "$@"
else
prek run --from-ref trunk --to-ref "$HEAD"
fi
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
#
# Part of the Carbon Language project, under the Apache License v2.0 with LLVM
# Exceptions. See /LICENSE for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# Runs prek over the commits `jj git push` would send, and pushes only if they
# pass. Takes the same arguments as `jj git push`.
set -eu
JJ_PREK="$(dirname "${BASH_SOURCE[0]}")/jj_prek.sh"
# Succeeds if the working-copy commit is in the given revset. This snapshots the
# working copy, so it sees anything the hooks rewrote.
working_copy_is() {
[[ -n "$(jj log --no-graph -r "@ & ($1)" --template 'commit_id')" ]]
}
# Succeeds if the working copy is an empty, undescribed child of the target,
# which hooks can run in and write their fixes into.
working_copy_sits_on() {
working_copy_is "empty() & description(exact:\"\") & children($1)"
}
# Returns the working copy to where it started. jj discards an empty,
# undescribed commit when the working copy moves off it, so the original may be
# gone. Build a new one on the same parents in that case.
restore_working_copy() {
if [[ -n "$(jj log --no-graph --ignore-working-copy \
-r "present($ORIG_CHANGE)" --template 'commit_id')" ]]; then
jj edit --quiet "$ORIG_CHANGE"
else
jj new --quiet $ORIG_PARENTS
fi
}
# `--help` describes `jj git push`, and has nothing to check.
for arg in "$@"; do
case "$arg" in
-h | --help)
exec jj git push "$@"
;;
esac
done
# Ask jj what the push would do. This checks the arguments and lists the commits
# being sent, without contacting the remote. A `--dry-run` already in `$@` is
# harmless here, and still suppresses the push at the end.
if ! PLAN="$(jj git push --dry-run "$@" 2>&1)"; then
echo "$PLAN" >&2
exit 1
fi
REMOTE="$(sed -n 's/^Changes to push to \(.*\):$/\1/p' <<<"$PLAN")"
# Each updated bookmark or tag reports the commit it moves to. Deletions have no
# such commit, and send nothing to check.
TARGETS="$(sed -n 's/^ \(bookmark\|tag\): .* to \([0-9a-f]\{8,\}\)\]$/\2/p' <<<"$PLAN" |
paste -sd '|')"
# Nothing to check, so just push.
if [[ -z "$REMOTE" || -z "$TARGETS" ]]; then
exec jj git push "$@"
fi
# Only the heads need checking; a head's range covers everything below it.
HEADS="$(jj log --no-graph --ignore-working-copy -r "heads($TARGETS)" \
--template 'commit_id ++ "\n"')"
ORIG_CHANGE="$(jj log --no-graph -r @ --template 'change_id')"
ORIG_PARENTS="$(jj log --no-graph -r 'parents(@)' --template 'commit_id ++ " "')"
for target in $HEADS; do
# Check with the working copy on top of the target.
made_scratch=0
if ! working_copy_sits_on "$target"; then
jj new --quiet "$target"
made_scratch=1
fi
# Check from the newest ancestor already on the remote. When there is none,
# there is no range to diff, so check every file.
base="$(jj log --no-graph --ignore-working-copy \
-r "heads(::$target & ::remote_bookmarks(remote=exact:$REMOTE))" \
--template 'commit_id ++ "\n"' | head -n 1)"
if [[ -n "$base" ]]; then
check=(--from-ref "$base" --to-ref "$target")
else
check=(--all-files)
fi
result=0
"$JJ_PREK" "${check[@]}" || result=$?
# Discard the scratch commit unless the hooks wrote something into it.
if ((made_scratch)) && working_copy_is 'empty()'; then
restore_working_copy
fi
if ((result)); then
echo >&2
if ! working_copy_is 'empty()'; then
change="$(jj log --no-graph -r @ --template 'change_id.shortest()')"
echo "Hooks changed files. They are in $change." >&2
fi
echo "Error: checks failed, nothing pushed." >&2
exit 1
fi
done
exec jj git push "$@"