Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
just: Introduce task manager for git hooks
Merged ade opened 22 days ago

As part of discussions around task runners for Radicle, just became a candidate over make and a few others due to its focus solely on task running and no build system.

This patch introduces just as a git hook task runner and manager. We previously managed hooks with the flake.nix which is great for nix users but not the wider community.

Further discussion was had around pre-commit as the flake.nix could be converted to a pre-commit config, however it was decided this would prevent non-nix users from contributing to the git hooks system easily.

In this patch we introduce a justfile with commands for all previous git hook checks from flake.nix.

We also introduce a git-hook-template.sh which is a thin shim over the just commands for each hook: pre-commit, post-checkout, pre-push. The shim checks a set of ‘sensitive’ files against master and if there are changes, warns the users and has them explicitly consent to running hook commands. This should provide a safety barrier for smuggled git hook overrides in large patches that may cause arbitrary code execution on reviewers machines.

15 files changed +332 -10 430868ff 07c62449
added .codespell-dictionary.txt
@@ -0,0 +1 @@
+
...->…
modified .codespellrc
@@ -1,4 +1,5 @@
[codespell]
-
skip = .git*,*.lock,.codespellrc,target,.jj
+
skip = .git*,*.lock,.codespellrc,target,.jj,.direnv
check-hidden = true
-
ignore-words-list = set,noes
+
ignore-words-list = ser,set,noes
+
dictionary = .codespell-dictionary.txt,-

\ No newline at end of file
modified .typos.toml
@@ -11,6 +11,9 @@ extend-ignore-re = [
[default.extend-identifiers]
"typ" = "typ" # We may write "typ" instead of "type". The latter is a Rust keyword.

+
[default.extend-words]
+
"..." = "…"
+

[type.codespell]
check-file = false
extend-glob = [".codespellrc"]
modified CONTRIBUTING.md
@@ -35,15 +35,29 @@ simple guidelines.

Patch formatting follows the same rules as commit formatting. See below.

+
### Git hooks & Task runner
+

+
We use [`just >= v1.49.0`](https://just.systems/) as our task runner. You can see all available commands by running `just` or `just --list` in the repository root.
+

+
If you are not using Nix (which sets up hooks automatically), you should install the local git hooks:
+

+
    $ just install-hooks
+

+
These hooks will run formatting, linting, and spelling checks on `pre-commit` and `pre-push`. For security, our hooks are copied rather than symlinked. If you check out a branch that modifies sensitive files (like `build.rs` or `justfile`), the hook will pause and ask for your confirmation before executing any code.
+

### Linting & formatting

Always check your code with the linter (`clippy`), by running:

-
    $ cargo clippy --workspace --tests
+
    $ just lint-rust
+

+
And make sure your code is formatted, using:
+

+
    $ just format-rust

-
And make sure your code is formatted with, using:
+
You can also run the entire suite of pre-commit checks (which includes spelling and shell checks) with:

-
    $ cargo fmt
+
    $ just pre-commit

Finally, ensure there is no trailing whitespace anywhere.

@@ -61,7 +75,7 @@ without effectively testing anything.
If you make documentation changes, you may want to check whether there are any
warnings or errors:

-
    $ cargo doc --workspace --all-features
+
    $ just check-docs

### Code style

modified HACKING.md
@@ -29,6 +29,16 @@ The repository is structured in *crates*, as follows:
* `radicle-term`: A generic terminal library used by the Radicle CLI.
* `radicle-tools`: Tools used to aid in the development of Radicle.

+
## Task runner
+

+
We use [`just >= v1.49.0`](https://just.systems/) to manage project tasks such as linting, formatting, and installing git hooks. To see a list of all available commands, run:
+

+
    $ just
+

+
If you are not using Nix, it is highly recommended to install the git hooks to automatically run checks before committing and pushing:
+

+
    $ just install-hooks
+

## Running in debug mode

To run the services or the CLI in debug mode, use `cargo run -p <package>`.
modified crates/radicle-cli/src/commands/issue/cache.rs
@@ -39,9 +39,9 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {

fn cache(id: Option<IssueId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
    let mut issues = {
-
        // NOTE: Since we require a cache that is writeable, on top of a store that
+
        // NOTE: Since we require a cache that is writable, on top of a store that
        // is read-only, we can neither use [`term::cob::issues_mut`] nor [`term::cob::issues`]
-
        // since these convenience functions pair a writeable cache with a writeable
+
        // since these convenience functions pair a writable cache with a writable
        // store, and respectively a read-only cache with a read-only store.

        let db = profile.cobs_db_mut()?;
modified crates/radicle-cli/src/commands/patch/cache.rs
@@ -39,9 +39,9 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {

fn cache(id: Option<PatchId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
    let mut patches = {
-
        // NOTE: Since we require a cache that is writeable, on top of a store that
+
        // NOTE: Since we require a cache that is writable, on top of a store that
        // is read-only, we can neither use [`term::cob::patches_mut`] nor [`term::cob::patches`]
-
        // since these convenience functions pair a writeable cache with a writeable
+
        // since these convenience functions pair a writable cache with a writable
        // store, and respectively a read-only cache with a read-only store.

        let db = profile.cobs_db_mut()?;
modified flake.nix
@@ -362,6 +362,8 @@
          cargo-watch
          cargo-nextest
          cargo-semver-checks
+
          codespell
+
          just
          ripgrep
          sqlite
        ];
added justfile
@@ -0,0 +1,145 @@
+
hooks := "pre-commit pre-push post-checkout commit-msg"
+
hook-script := "scripts/git-hook-template.sh"
+

+
WARN := "⚠️ " + YELLOW + BOLD
+
SUCCESS := "✅ " + GREEN + BOLD
+
ERROR := "❌ " + RED + BOLD
+
HINT := "💡 " + BOLD
+
CHECK := "🔄 " + BOLD
+

+
default: check-hooks
+
    @just --list
+

+
# Run post-checkout checks
+
[group('hooks')]
+
post-checkout:
+

+
# Run commit-msg checks
+
[group('hooks')]
+
commit-msg file: (verify-tool "typos" "typos-cli")
+
    @echo "{{CHECK}}Checking commit message for typos...{{NORMAL}}"
+
    @while ! typos "{{file}}"; do \
+
        exec < /dev/tty; \
+
        echo ""; \
+
        printf "{{WARN}}Typos found.{{NORMAL}} (e)dit, (c)ontinue, or (a)bort? [E/c/a] "; \
+
        read -r response; \
+
        case "$response" in \
+
            [cC]*) exit 0 ;; \
+
            [aA]*) exit 1 ;; \
+
            *) ${EDITOR:-vi} "{{file}}" ;; \
+
        esac; \
+
    done
+

+
# Run pre-commit checks
+
[group('hooks')]
+
pre-commit: format-rust check-rust check-docs check-typos check-spelling check-scripts check-keywords format-nix
+
    @echo ""
+
    @echo "{{SUCCESS}}pre-commit passed!{{NORMAL}}"
+
    @echo ""
+

+
# Format Rust code
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('format')]
+
[parallel]
+
format-rust: (verify-tool "cargo")
+
    @echo "{{CHECK}}Cargo fmt...{{NORMAL}}"
+
    @cargo fmt --all
+

+
# Run cargo check
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-rust:
+
    @echo "{{CHECK}}Cargo check...{{NORMAL}}"
+
    @cargo check --workspace --all-targets --all-features
+

+
# Check documentation for warnings
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-docs:
+
    @echo "{{CHECK}}Checking docs for warnings...{{NORMAL}}"
+
    @RUSTDOCFLAGS="--deny warnings" cargo doc --workspace --all-features --no-deps
+

+
# Check for typos
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-typos: (verify-tool "typos" "typos-cli")
+
    @echo "{{CHECK}}Checking for spelling typos...{{NORMAL}}"
+
    @typos
+

+
# Run codespell
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-spelling: (verify-tool "codespell")
+
    @echo "{{CHECK}}Checking for code typos...{{NORMAL}}"
+
    @git ls-files -z | xargs -0 codespell --write-changes --check-filenames
+

+
# just runs with `/bin/sh` which has no doublestar glob
+
# expansion, furthermore, `time ls **/*.sh` takes ~5s
+
# locally. The `find` solution below is fastest ~900ms.
+
#
+
# Run shellcheck on all shell scripts
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-scripts: (verify-tool "shellcheck")
+
    @echo "{{CHECK}}Checking shell scripts...{{NORMAL}}"
+
    @find . -type f -name "*.sh" -exec shellcheck {} +
+

+
# Run checks for forbidden keywords
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('check')]
+
[parallel]
+
check-keywords: (verify-tool "rg" "ripgrep")
+
    @CHECK="{{CHECK}}" NORMAL="{{NORMAL}}" scripts/just/check-keywords.sh
+

+
# Format Nix files
+
[group('pre-commit')]
+
[group('pre-push')]
+
[group('format')]
+
[parallel]
+
format-nix:
+
    @scripts/just/format-nix.sh
+

+
# Run pre-push checks
+
[group('hooks')]
+
pre-push: format-rust check-rust check-keywords check-docs check-spelling check-scripts check-typos format-nix lint-rust
+
    @echo ""
+
    @echo "{{SUCCESS}}pre-push passed!{{NORMAL}}"
+
    @echo ""
+

+
# Run Clippy lints
+
[group('pre-push')]
+
lint-rust: (verify-tool "cargo")
+
    @echo "{{CHECK}}Cargo clippy...{{NORMAL}}"
+
    @cargo clippy --workspace --all-targets --all-features -- --deny warnings
+

+
# Check if required tools are in PATH.
+
[private]
+
verify-tool tool package_name="":
+
    @ERROR="{{ERROR}}" NORMAL="{{NORMAL}}" HINT="{{HINT}}" scripts/just/verify-tool.sh "{{tool}}" "{{package_name}}"
+

+
# SECURITY: We COPY the hook template instead of symlinking it. This ensures that
+
# checking out an untrusted patch won't overwrite your local git hooks. The copied
+
# script also checks if sensitive files (like build.rs or justfile) were modified
+
# in the patch and prompts for confirmation, preventing arbitrary code execution.
+
#
+
# Install git hooks
+
[group('hooks')]
+
install-hooks:
+
    @SUCCESS="{{SUCCESS}}" NORMAL="{{NORMAL}}" scripts/just/install-hooks.sh "{{hook-script}}" "{{hooks}}"
+

+
# Check for missing or changed hooks
+
[group('hooks')]
+
check-hooks:
+
    @HINT="{{HINT}}" NORMAL="{{NORMAL}}" WARN="{{WARN}}" scripts/just/check-hooks.sh "{{hook-script}}" "{{hooks}}"
added scripts/git-hook-template.sh
@@ -0,0 +1,50 @@
+
#! /usr/bin/env bash
+
set -euo pipefail
+

+
readonly HOOK="${HOOK:-$(basename "$0")}"
+

+
if ! [[ "$HOOK" =~ ^(pre-(commit|push)|post-checkout|commit-msg)$ ]]
+
then
+
    echo "Unknown hook '${HOOK}'."
+
    exit 1
+
fi
+

+
readonly SENSITIVE_FILES=("justfile" "build.rs" "rust-toolchain.toml")
+
readonly BASE_BRANCH="master"
+

+
# Check which files were modified compared to the base branch.
+
mapfile -t CHANGED_FILES < <(comm -12 \
+
    <(git diff --name-only master | sort) \
+
    <(IFS=$'\n'; echo "${SENSITIVE_FILES[*]}" | sort) \
+
)
+

+
if [ ${#CHANGED_FILES[@]} -gt 0 ]
+
then
+
    echo "⚠️ WARNING: Sensitive files have been modified relative to $BASE_BRANCH."
+
    echo "Executing this hook may run arbitrary code from the modified files."
+
    echo ""
+

+
    git --no-pager diff "$BASE_BRANCH" -- "${SENSITIVE_FILES[@]}"
+

+
    # Read from /dev/tty because stdin is not attached to the terminal in Git hooks.
+
    exec < /dev/tty
+

+
    read -r -p "⚠️ Do you want to continue executing the '${HOOK}' hook? [y/N] " response
+
    case "$response" in
+
        [yY][eE][sS]|[yY])
+
            echo "Continuing with '${HOOK}' hook…"
+
            ;;
+
        *)
+
            echo "Skipping '${HOOK}' hook."
+
            exit 0
+
            ;;
+
    esac
+
fi
+

+
# Execute the appropriate just recipe based on the hook name.
+
if [ "$HOOK" = "commit-msg" ]
+
then
+
    just "$HOOK" "$1"
+
else
+
    just "$HOOK"
+
fi
added scripts/just/check-hooks.sh
@@ -0,0 +1,36 @@
+
#! /usr/bin/env bash
+
set -e
+

+
HOOK_SCRIPT=$1
+
HOOKS=$2
+

+
TEMPLATE="$HOOK_SCRIPT"
+
OUTDATED=()
+
MISSING=0
+
TOTAL=0
+

+
for hook in $HOOKS; do
+
    TOTAL=$((TOTAL + 1))
+
    if [ ! -f ".git/hooks/$hook" ]; then
+
        MISSING=$((MISSING + 1))
+
        OUTDATED+=("$hook")
+
    elif ! cmp -s "$TEMPLATE" ".git/hooks/$hook"; then
+
        OUTDATED+=("$hook")
+
    fi
+
done
+

+
if [ "$MISSING" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
+
    echo ""
+
    echo "${HINT}No git hooks are installed. Run 'just install-hooks' to install them.${NORMAL}"
+
    echo ""
+
elif [ ${#OUTDATED[@]} -gt 0 ]; then
+
    echo ""
+
    echo "${WARN}WARNING: The following git hooks are missing or out of date:${NORMAL}"
+
    echo ""
+
    for hook in "${OUTDATED[@]}"; do
+
        echo -e "\t$hook"
+
    done
+
    echo ""
+
    echo "${HINT}Check them with 'diff $HOOK_SCRIPT .git/hooks/<hook name>' then run 'just install-hooks'${NORMAL}"
+
    echo ""
+
fi
added scripts/just/check-keywords.sh
@@ -0,0 +1,24 @@
+
#! /usr/bin/env bash
+
set -e
+
echo "${CHECK}Checking for forbidden words in staged files...${NORMAL}"
+

+
# Get staged Rust files
+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.rs$' || true)
+

+
if [ -n "$STAGED_FILES" ]; then
+
    if echo "$STAGED_FILES" | xargs rg --context=3 --fixed-strings 'radicle.xyz'; then
+
        exit 1
+
    fi
+
    
+
    if echo "$STAGED_FILES" | xargs rg --context=3 --fixed-strings 'radicle.zulipchat.com'; then
+
        exit 1
+
    fi
+

+
    # For `git2::` we need to exclude raw.rs
+
    FILTERED_GIT2=$(echo "$STAGED_FILES" | grep '^crates/radicle/.*\.rs$' | grep -v 'crates/radicle/src/git/raw.rs' || true)
+
    if [ -n "$FILTERED_GIT2" ]; then
+
        if echo "$FILTERED_GIT2" | xargs rg --context=3 --fixed-strings 'git2::'; then
+
            exit 1
+
        fi
+
    fi
+
fi
added scripts/just/format-nix.sh
@@ -0,0 +1,7 @@
+
#! /usr/bin/env bash
+

+
if command -v alejandra >/dev/null 2>&1; then
+
    alejandra --check .
+
else
+
    echo "⏭️ alejandra not found, skipping Nix formatting."
+
fi
added scripts/just/install-hooks.sh
@@ -0,0 +1,18 @@
+
#! /usr/bin/env bash
+
set -e
+

+
HOOK_SCRIPT=$1
+
HOOKS=$2
+

+
read -r -p "Overwrite existing hooks '${HOOKS}'? [y/N] " confirm
+
[[ "$confirm" == "y" ]] || exit 1
+

+
for hook in $HOOKS; do
+
    if [ -f ".git/hooks/$hook" ]; then
+
      rm ".git/hooks/$hook"
+
    fi
+
    cp "$HOOK_SCRIPT" ".git/hooks/$hook"
+
    chmod +x ".git/hooks/$hook"
+
done
+
echo ""
+
echo "${SUCCESS}Hooks installed: ${HOOKS}${NORMAL}"
added scripts/just/verify-tool.sh
@@ -0,0 +1,11 @@
+
#! /usr/bin/env bash
+
set -e
+
TOOL=$1
+
PKG_NAME=$2
+

+
if ! command -v "$TOOL" >/dev/null 2>&1; then
+
    PKG="${PKG_NAME:-$TOOL}"
+
    echo "${ERROR}Missing required tool: ${TOOL}${NORMAL}"
+
    echo "${HINT}Use your systems package manager to install '$PKG'.${NORMAL}"
+
    exit 1
+
fi