#!/usr/bin/env bash # # Check a commit message's Conventional Commits scope against plan/. # # An epic's id is its commit scope, so `feat(camo): ...` belongs to # plan/camo.md. This catches the two ways that goes wrong: a typo, and a scope # naming an epic nobody has written down. # # Valid ids are the filenames in plan/ and plan/complete/, where finished # epics are archived. It used to read plan/README.md's tables as well, for an # epic that was a row before it was a file; those tables are generated from # the files now (scripts/gen-plan-readme.py), so a row that no file backs # cannot exist and reading them would only find the same ids twice. # # It is not a rule that every commit belongs to an epic. A commit with no # scope passes, and so does anything in NON_EPIC_SCOPES — build work, # dependency bumps and repo chores are not epics and should not have to # pretend otherwise. What fails is a scope that is neither. # # Run by prek at the commit-msg stage, with the message file as $1. set -euo pipefail # Scopes that are deliberately not epics. Keep this short: every entry is a # commit that cannot be found from plan/. NON_EPIC_SCOPES="plan deps scripts deploy ci" # The component scopes this repo used before epics. They still pass so that # adopting the hook does not reject ordinary work, but prefer the epic: `web` # says which directory changed, `camo` says what the change was for. This list # should shrink to nothing. LEGACY_SCOPES="web api matches manifest todo" msg_file="${1:?usage: check-commit-scope.sh }" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" plan_dir="$repo_root/plan" # 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 # type(scope)!: subject. No parentheses means no scope, which is allowed. The # pattern is a variable because bash's [[ ]] parser trips on an unquoted ")". subject_re='^[a-z]+\(([^)]*)\)!?:' if [[ ! "$subject" =~ $subject_re ]]; then exit 0 fi scopes="${BASH_REMATCH[1]}" known="$NON_EPIC_SCOPES $LEGACY_SCOPES" # Epics with a file, including the completed ones under plan/complete/. An # epic that is finished keeps its id: a follow-up fix to it is still that # epic's commit, and refusing the scope would only push the change under a # vaguer one. for f in "$plan_dir"/*.md "$plan_dir"/complete/*.md; do [ -e "$f" ] || continue id="$(basename "$f" .md)" [ "$id" = "README" ] && continue known="$known $id" done # Collapse whitespace so the membership test below has single separators. known="$(printf '%s' "$known" | tr -s '[:space:]' ' ')" 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 not in plan/:$bad" echo echo "A scope is an epic id, so that a commit is findable from the epic it" echo "belongs to. Use one of:" echo # shellcheck disable=SC2086 printf ' %s\n' $known | sort -u echo echo "or write plan/.md for the epic first, or drop the scope." } >&2 exit 1