jj-hooks 0.1.2

Run pre-commit / lefthook / hk hooks against jj bookmark pushes
Documentation
set shell := ["bash", "-c"]
set dotenv-load := false

default:
    @just --list

# Install all supported hook runners and the lint binaries hk.pkl needs.
# macOS uses Homebrew. Linux uses `uv` for the Python backends, `npm` for
# markdownlint-cli2, prebuilt tarballs for lefthook/actionlint, and
# `cargo binstall` for hk.
install-deps:
    #!/usr/bin/env bash
    set -euo pipefail
    case "$(uname -s)" in
        Darwin)
            brew install pre-commit prek lefthook hk markdownlint-cli2 actionlint
            ;;
        Linux)
            # Respect XDG_BIN_HOME when set (CI sets it so all installed
            # tools live under one cacheable dir). Default to ~/.local/bin
            # for local dev installs.
            bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}"
            mkdir -p "$bin_dir"
            export PATH="$bin_dir:$PATH"

            uv tool install pre-commit
            uv tool install prek

            lefthook_version=2.1.6
            arch="$(uname -m)"
            case "$arch" in
                x86_64)  lefthook_arch=x86_64; actionlint_arch=amd64 ;;
                aarch64) lefthook_arch=arm64;  actionlint_arch=arm64 ;;
                *)
                    echo "unsupported Linux arch: $arch" >&2
                    exit 1
                    ;;
            esac
            # lefthook's release assets are versioned and use capitalized
            # `Linux` in the filename -- e.g. lefthook_2.1.6_Linux_x86_64.
            curl -fsSL "https://github.com/evilmartians/lefthook/releases/download/v${lefthook_version}/lefthook_${lefthook_version}_Linux_${lefthook_arch}" \
                -o "$bin_dir/lefthook"
            chmod +x "$bin_dir/lefthook"

            # actionlint ships as a prebuilt tarball. Extract just the
            # binary into the chosen bin dir.
            actionlint_version=1.7.12
            curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${actionlint_version}/actionlint_${actionlint_version}_linux_${actionlint_arch}.tar.gz" \
                | tar -xz -C "$bin_dir" actionlint

            # pkl is a single-file binary. hk shells out to it to read
            # hk.pkl configs -- without pkl on PATH, hk silently fails to
            # parse and rejects everything as "no config".
            pkl_version=0.31.1
            case "$arch" in
                x86_64)  pkl_arch=amd64 ;;
                aarch64) pkl_arch=aarch64 ;;
            esac
            curl -fsSL "https://github.com/apple/pkl/releases/download/${pkl_version}/pkl-linux-${pkl_arch}" \
                -o "$bin_dir/pkl"
            chmod +x "$bin_dir/pkl"

            # markdownlint-cli2 lives on npm. We force the install prefix
            # to the chosen bin dir's parent so npm drops the executable
            # right next to the rest of our tools (npm puts binaries in
            # <prefix>/bin).
            if command -v npm >/dev/null 2>&1; then
                npm config set prefix "$(dirname "$bin_dir")"
                npm install -g markdownlint-cli2
            else
                echo "warn: npm not on PATH; install Node.js to get markdownlint-cli2" >&2
            fi

            # cargo binstall pulls prebuilt artifacts (much faster than
            # building from source). Bootstrap it if it's missing. We
            # install hk into the cacheable bin dir.
            if ! command -v cargo-binstall >/dev/null 2>&1; then
                cargo install cargo-binstall
            fi
            cargo binstall -y --install-path "$bin_dir" hk
            ;;
        *)
            echo "unsupported OS: $(uname -s)" >&2
            exit 1
            ;;
    esac

# Verify all four runners + the lint binaries hk.pkl needs are on PATH.
check-deps:
    #!/usr/bin/env bash
    set -euo pipefail
    missing=()
    for bin in pre-commit prek lefthook hk pkl markdownlint-cli2 actionlint; do
        if ! command -v "$bin" >/dev/null 2>&1; then
            missing+=("$bin")
        fi
    done
    if [ ${#missing[@]} -gt 0 ]; then
        echo "missing tools: ${missing[*]}" >&2
        echo "run \`just install-deps\` to install them" >&2
        exit 1
    fi
    echo "all tools installed"

build:
    cargo build --all-targets

# Run the full test suite. Requires `just install-deps` to have been run first.
test: check-deps
    cargo nextest run --no-fail-fast

# Run only unit / pure tests that don't need external binaries.
test-pure:
    cargo nextest run --no-fail-fast --test parse --test runner

fmt:
    cargo fmt --all

fmt-check:
    cargo fmt --all -- --check

clippy:
    cargo clippy --all-targets -- -D warnings

# Pre-commit check: fmt + clippy + tests.
ci: fmt-check clippy test

# Install a debug build to ~/.cargo/bin. Codesigns on macOS so the binary
# can be re-run without confirmation. No --release.
install-debug:
    #!/usr/bin/env bash
    set -euo pipefail
    cargo build --bin jj-hooks --bin jj-hp
    dest="${CARGO_HOME:-$HOME/.cargo}/bin"
    mkdir -p "$dest"
    # On Linux, writing over an in-use executable fails with ETXTBSY
    # (text file busy). Unlink first so a running process keeps its
    # inode while we drop a fresh one at the path. macOS lets you
    # overwrite an active binary, so the unlink is a no-op there.
    for bin in jj-hooks jj-hp; do
        rm -f "$dest/$bin"
        cp "target/debug/$bin" "$dest/$bin"
        if [[ "$(uname)" == "Darwin" ]]; then
            codesign -s - "$dest/$bin" 2>/dev/null && echo "Codesigned $bin" || true
        fi
    done
    echo "Installed debug builds (jj-hooks + jj-hp) to $dest"

# Install a release build to ~/.cargo/bin. Codesigns on macOS.
install: build-release
    #!/usr/bin/env bash
    set -euo pipefail
    dest="${CARGO_HOME:-$HOME/.cargo}/bin"
    mkdir -p "$dest"
    for bin in jj-hooks jj-hp; do
        rm -f "$dest/$bin"
        cp "target/release/$bin" "$dest/$bin"
        if [[ "$(uname)" == "Darwin" ]]; then
            codesign -s - "$dest/$bin" 2>/dev/null && echo "Codesigned $bin" || true
        fi
    done
    echo "Installed release builds (jj-hooks + jj-hp) to $dest"

build-release:
    cargo build --release --bin jj-hooks --bin jj-hp

# Cut a release. Bumps Cargo.toml, refreshes Cargo.lock, commits the
# bump on top of @, tags @- with the version, advances the local `main`
# bookmark to the release commit, and pushes both the commit and the
# tag to origin. Triggers the release.yml workflow on push.
#
# Usage: just release v0.1.0
release VERSION:
    #!/usr/bin/env bash
    set -euo pipefail

    version="{{ VERSION }}"
    if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
        echo "error: VERSION must look like v1.2.3 or v1.2.3-rc.1 (got: $version)" >&2
        exit 1
    fi
    bare="${version#v}"

    # Require a clean @ -- release commits should not include unrelated work.
    if [ -n "$(jj diff --summary --ignore-working-copy 2>/dev/null)" ]; then
        echo "error: working copy @ has uncommitted changes; finalize them first" >&2
        exit 1
    fi

    # Require `main` to be an ancestor of `@` so the release commit lands
    # on top of main. Otherwise advancing main to @- after the commit
    # would move it backwards or sideways onto an unrelated branch.
    if ! jj --ignore-working-copy log -r "main & ::@" -T 'change_id' --no-graph 2>/dev/null | grep -q .; then
        echo "error: @ is not a descendant of main (run \`jj rebase -d main\` first)" >&2
        exit 1
    fi

    if jj --ignore-working-copy tag list -T 'name ++ "\n"' 2>/dev/null | grep -qx "$version"; then
        echo "error: tag $version already exists" >&2
        exit 1
    fi

    if ! cargo set-version --help >/dev/null 2>&1; then
        echo "error: cargo-edit not installed (run: cargo install --locked cargo-edit)" >&2
        exit 1
    fi

    echo "Setting package version to $bare..."
    cargo set-version "$bare"
    echo

    echo "Updating Cargo.lock..."
    cargo update --workspace
    echo

    echo "Committing release bump as a new jj change on top of @..."
    jj commit -m "release: $version"
    echo

    echo "Tagging @- with $version..."
    jj tag set "$version" -r @-
    echo

    # Move the local `main` bookmark forward to the release commit so
    # `jj git push` pushes the right ref.
    echo "Advancing main to the release commit..."
    jj bookmark set main -r @-
    echo

    echo "Exporting refs to git..."
    jj --ignore-working-copy git export >/dev/null 2>&1 || true
    echo

    echo "Pushing main..."
    jj git push -b main
    echo

    echo "Pushing tag $version (triggers release.yml)..."
    jj-push-tags "$version"
    echo

    echo "Done. Watch the release workflow:"
    echo "  https://github.com/mattwilkinsonn/jj-hooks/actions/workflows/release.yml"