diff --git a/README.md b/README.md index c2a5a1b..cf9275c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,30 @@ cargo build --release The `.sqlite` is not committed. It is derived, it is 30MB, and MegaMek's data is CC BY-NC-SA 4.0 — build it locally. +## Hooks + +``` +prek install +``` + +Installs the pre-commit and commit-msg hooks from `prek.toml`. Same set as the +sibling repos where the file types overlap — whitespace, TOML and XML checks, +shellcheck, `cargo fmt` and `cargo clippy` — plus two this repo needs of its +own. + +`crate-boundaries` runs `scripts/check-boundaries.sh` when a `Cargo.toml` +changes. That is the file a boundary breaks in, and the breakage otherwise +surfaces much later as "why can this not compile to wasm". + +`commit-scope` checks the subject is Conventional Commits and that its scope +names an epic in headquarters' `plan/` — `unit-library`, `unit-search`, +`unit-rules` — or one of a short list of non-epic scopes. A commit with no +scope passes. + +`cargo clippy` runs with `-j 2` here where the sibling repos do not bother: a +full-parallel cargo build has taken this machine down, and a hook runs on +every commit. + ## What comes from where `helm-unitfile` reads MegaMek's two formats directly: `.mtf` for Meks, `.blk` diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..ce18c57 --- /dev/null +++ b/prek.toml @@ -0,0 +1,84 @@ +# Hooks for prek (https://prek.j178.dev/). See README for setup. +# +# Same shape as the sibling repos, minus what this one has no files for: +# no terraform (infra's), no prettier or astro (headquarters' web), no ruff +# (arena's Python helpers, and there is no Python here). + +# The commit-msg hook needs its own git shim, so `prek install` alone does not +# get it. Naming it here means a plain `prek install` installs both. +default_install_hook_types = ["pre-commit", "commit-msg"] + +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v6.0.0" +# check-xml earns its place on one file, bridge/log4j2-quiet.xml, which +# nothing else parses until MegaMek refuses to start. check-yaml and +# check-json are here for the CI config this repo does not have yet; they cost +# nothing while they match nothing. +# +# check-added-large-files matters more here than in the sibling repos: the +# built database is 100MB and the bridge's dumps are 33MB, and while +# .gitignore covers them by name, a differently-named copy would sail in. +hooks = [ + { id = "check-merge-conflict" }, + { id = "check-added-large-files" }, + { id = "check-toml" }, + { id = "check-yaml" }, + { id = "check-json" }, + { id = "check-xml" }, + { id = "mixed-line-ending", args = ["--fix=lf"] }, + { id = "end-of-file-fixer" }, + { id = "trailing-whitespace" }, +] + +[[repos]] +repo = "https://github.com/shellcheck-py/shellcheck-py" +rev = "v0.11.0.1" +# Same flags as arena's and headquarters' gates: -x follows sourced files, and +# info-level notes do not gate. +hooks = [{ id = "shellcheck", args = ["-x", "--severity=warning"] }] + +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "cargo-fmt" +name = "cargo fmt" +language = "system" +entry = "cargo fmt --all --check" +pass_filenames = false +files = '\.rs$' + +# -j 2, which the sibling repos do not need. A full-parallel cargo build has +# taken this machine down before, and a hook runs on every commit, so it is +# the last place to let one off the leash. +[[repos.hooks]] +id = "cargo-clippy" +name = "cargo clippy" +language = "system" +entry = "cargo clippy -j 2 --workspace --all-targets -- -D warnings" +pass_filenames = false +files = '(\.rs|Cargo\.(toml|lock))$' + +# The crate graph this repo's design rests on: no I/O dependency may reach the +# crates that have to compile to wasm, and no producer of ComputedStats may +# reach helm-db. Both erode by adding one line to a Cargo.toml, and the +# failure surfaces much later as "why can this not compile to wasm", so the +# trigger is Cargo.toml rather than every Rust file - a `use` cannot add a +# dependency, and this way the hook stays off the common path. +[[repos.hooks]] +id = "crate-boundaries" +name = "crate boundaries" +language = "system" +entry = "scripts/check-boundaries.sh" +pass_filenames = false +files = 'Cargo\.toml$' + +# Conventional Commits, with the scope naming the epic in headquarters' plan/ +# that the work belongs to. The script says what else is allowed and why. +[[repos.hooks]] +id = "commit-scope" +name = "commit scope" +language = "system" +entry = "scripts/check-commit-scope.sh" +stages = ["commit-msg"] diff --git a/scripts/check-commit-scope.sh b/scripts/check-commit-scope.sh new file mode 100755 index 0000000..afc0e03 --- /dev/null +++ b/scripts/check-commit-scope.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Check a commit message against Conventional Commits, and its scope against +# the epics this repo's work belongs to. +# +# Two differences from headquarters' hook of the same name, both deliberate. +# +# It enforces the *form* as well as the scope. headquarters lets an +# unconventional subject through, because that repo has years of them; this +# one has had the convention from its second commit and there is no reason to +# let it slip. +# +# And the epic ids are written down here rather than read from plan/, because +# helm has no plan/ - its epics live in headquarters, which is not guaranteed +# to be checked out beside this repo and is certainly not guaranteed to be at +# any particular revision. A hardcoded list that is occasionally stale is +# better than a hook that passes silently whenever a sibling directory is +# missing. +# +# Run by prek at the commit-msg stage, with the message file as $1. + +set -euo pipefail + +# Conventional Commits types. +TYPES="feat fix docs style refactor perf test build ci chore revert" + +# Epic ids from headquarters' plan/. Add one here when an epic that helm does +# work for is added there. +EPIC_SCOPES="unit-library unit-search unit-rules" + +# Scopes that are deliberately not epics. Keep this short: every entry is a +# commit that cannot be found from an epic. +NON_EPIC_SCOPES="deps scripts bridge ci repo" + +msg_file="${1:?usage: check-commit-scope.sh }" + +# First line that is not a comment or blank; git strips the comments later. +subject="$(grep -v '^#' "$msg_file" | grep -m1 . || true)" + +# An empty message aborts the commit on git's side, so it is not this hook's +# to complain about. +[ -n "$subject" ] || exit 0 + +# A revert or a merge git wrote itself is not the author's prose to police. +case "$subject" in +Revert\ * | Merge\ *) exit 0 ;; +esac + +# type(scope)!: subject, where the scope is optional. The pattern is a +# variable because bash's [[ ]] parser trips on an unquoted ")". +full_re='^([a-z]+)(\(([^)]*)\))?!?: .+' +if [[ ! "$subject" =~ $full_re ]]; then + { + echo "commit subject is not Conventional Commits:" + echo " $subject" + echo + echo "Write: type(scope): what changed" + echo " type: what changed" + echo + # shellcheck disable=SC2086 + echo "Types: $(printf '%s, ' $TYPES | sed 's/, $//')" + echo "Scopes: $EPIC_SCOPES $NON_EPIC_SCOPES" + } >&2 + exit 1 +fi + +type="${BASH_REMATCH[1]}" +scopes="${BASH_REMATCH[3]}" + +case " $TYPES " in +*" $type "*) ;; +*) + { + echo "unknown commit type: $type" + echo + # shellcheck disable=SC2086 + printf ' %s\n' $TYPES | sort + } >&2 + exit 1 + ;; +esac + +# No scope is allowed: not every commit belongs to an epic. +[ -n "$scopes" ] || exit 0 + +known="$EPIC_SCOPES $NON_EPIC_SCOPES" +bad="" +IFS=',' read -ra parts <<<"$scopes" +for scope in "${parts[@]}"; do + scope="$(printf '%s' "$scope" | tr -d '[:space:]')" + [ -n "$scope" ] || continue + case " $known " in + *" $scope "*) ;; + *) bad="$bad $scope" ;; + esac +done + +[ -n "$bad" ] || exit 0 + +{ + echo "commit scope is not an epic or a known non-epic:$bad" + echo + echo "A scope names the epic in headquarters' plan/ that the work belongs" + echo "to, so a commit is findable from it. Use one of:" + echo + # shellcheck disable=SC2086 + printf ' %s\n' $known | sort -u + echo + echo "or drop the scope. If a new epic exists in headquarters, add its id" + echo "to EPIC_SCOPES in this script." +} >&2 +exit 1