build(git-hooks): enforce Conventional Commits via a hook + CI, record history decision
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 12s
ci / build-and-test (push) Successful in 50s
commit-lint / conventional-commits (push) Successful in 3s

The convention was documented in CONTRIBUTING.md but nothing enforced it, and no
decision was on record for the pre-self-hosting `Phase` history.

- tools/git-hooks/commit-msg — rejects a summary that is not a Conventional
  Commit. tools/git-hooks/lib.sh holds the single rule (types, scope, `!`, and
  the merge/revert/autosquash exemptions) so the hook and CI cannot drift.
- tools/git-hooks/lint-range.sh — lints a commit range with that same rule.
- .forgejo/workflows/commit-lint.yml — runs it over the new commits on every
  push and PR, as the backstop for contributors who have not enabled the hook.
- Fix the pre-commit hook, which pointed at the old build/ludic-fmt path (the
  toolchain moved to bin/) and so silently no-op'd; it now finds bin/ludic-fmt.
- CONTRIBUTING.md — full type/scope table, the one-line enable
  (`git config core.hooksPath tools/git-hooks`), and a **Git history** section
  recording the decision: leave the pushed `Phase`-era history as-is (a rewrite
  is destructive and non-reversible for anyone who cloned); enforce the
  convention going forward; let #33's first release tag double as the clean `v0`
  baseline that brackets the old prefix without touching a commit.

Verified: the hook accepts feat/fix/ci/refactor(!)/merge/revert and rejects
"added regex" / "Fix bug" / "WIP"; lint-range passes recent history and flags
the old `Phase 8b:` commit.

Closes #34

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 23:30:45 +03:00
parent 1c1192e7c0
commit 709465cdd8
6 changed files with 193 additions and 6 deletions

25
tools/git-hooks/commit-msg Executable file
View file

@ -0,0 +1,25 @@
#!/bin/sh
# Reject a commit whose summary line is not a Conventional Commit.
#
# Enable every hook in this directory at once with:
#
# git config core.hooksPath tools/git-hooks
#
# (git then runs the hooks straight from the tree — no per-hook symlink, and new
# hooks activate as soon as they land on your branch.)
set -e
MSG_FILE="$1"
# The header is the first line that is neither blank nor a comment (git strips
# '#' lines itself, but a commit-msg hook runs before that, so skip them here).
HEADER=$(grep -vE '^[[:space:]]*#' "$MSG_FILE" | sed '/^[[:space:]]*$/d' | head -n1)
. "$(dirname "$0")/lib.sh"
if conventional_ok "$HEADER"; then
exit 0
fi
echo "commit-msg hook: rejected." >&2
conventional_help "$HEADER" >&2
exit 1

34
tools/git-hooks/lib.sh Normal file
View file

@ -0,0 +1,34 @@
# lib.sh — shared Conventional Commits validation, sourced by the commit-msg
# hook and by the CI commit-lint job so the rule lives in exactly one place.
# POSIX sh; no bashisms.
# The allowed commit types (Conventional Commits + the ones this repo uses).
CONVENTIONAL_TYPES='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
# conventional_ok <header-line> -> 0 if it conforms, non-zero otherwise.
# Accepts `type(scope): summary`, an optional `!` for a breaking change, and the
# housekeeping headers git itself writes (merge/revert) or autosquash markers.
conventional_ok() {
header="$1"
[ -n "$header" ] || return 1
case "$header" in
"Merge "*|"Revert "*|"fixup! "*|"squash! "*|"amend! "*) return 0 ;;
esac
printf '%s\n' "$header" \
| grep -qE "^($CONVENTIONAL_TYPES)(\([a-z0-9][a-z0-9,._/ -]*\))?(!)?: .+"
}
# conventional_help <header-line> — print a short, actionable explanation.
conventional_help() {
echo " commit summary is not a Conventional Commit:"
echo " ${1:-<empty>}"
echo
echo " expected: type(scope): summary (scope is optional)"
echo " types: feat fix docs style refactor perf test build ci chore revert"
echo " examples: feat(stdlib): add Regex.* namespace"
echo " fix(emit): stop fixed compares widening the int literal"
echo " ci: cache the clang toolchain between runs"
echo " refactor(stdlib)!: drop the deprecated System.env alias"
echo
echo " see https://www.conventionalcommits.org"
}

33
tools/git-hooks/lint-range.sh Executable file
View file

@ -0,0 +1,33 @@
#!/bin/sh
# Lint every commit summary in a git range against the Conventional Commits rule.
# Used by the commit-lint CI job; usable by hand too:
#
# tools/git-hooks/lint-range.sh origin/main..HEAD
#
# With no argument it lints just the tip commit. Reports every commit, then exits
# non-zero if any failed.
set -e
RANGE="${1:-HEAD~1..HEAD}"
. "$(dirname "$0")/lib.sh"
# Collect subjects into a file so the loop runs in this shell (a pipe would put
# it in a subshell and lose the verdict). Merge commits are exempt by the rule
# and skipped here so a merge subject never trips the lint.
tmp=$(mktemp)
git log --no-merges --format='%s' "$RANGE" > "$tmp"
fail=0
while IFS= read -r subject; do
[ -n "$subject" ] || continue
if conventional_ok "$subject"; then
echo " ok $subject"
else
echo " FAIL $subject"
conventional_help "$subject"
fail=1
fi
done < "$tmp"
rm -f "$tmp"
[ "$fail" -eq 0 ]

View file

@ -2,12 +2,14 @@
# Reject a commit that contains unformatted Ludic — in source or in the ```ludic
# fences of a Markdown file.
#
# ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
# Enable this and the commit-msg hook together with:
#
# git config core.hooksPath tools/git-hooks
#
# Checks only the files being committed, so it stays fast on a big tree.
set -e
ROOT=$(git rev-parse --show-toplevel)
FMT="$ROOT/build/ludic-fmt"
FMT="$ROOT/bin/ludic-fmt"
[ -x "$FMT" ] || exit 0 # toolchain not built: do not block the commit