diff --git a/.forgejo/workflows/commit-lint.yml b/.forgejo/workflows/commit-lint.yml new file mode 100644 index 00000000..f5fbbc20 --- /dev/null +++ b/.forgejo/workflows/commit-lint.yml @@ -0,0 +1,48 @@ +name: commit-lint + +# Enforce Conventional Commits in CI, as a backstop to the local commit-msg hook +# (which a contributor only gets after `git config core.hooksPath tools/git-hooks`). +# Lints every new commit's summary line against tools/git-hooks/lib.sh — the same +# rule the hook uses, so the two can never drift. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: {} + +jobs: + conventional-commits: + runs-on: docker + container: node:20-bookworm + steps: + - name: Check out with history + env: + BEFORE: ${{ github.event.before }} + BASE: ${{ github.base_ref }} + run: | + set -eu + git config --global --add safe.directory '*' + # Full clone so both endpoints of the range are present. + git clone https://git.workshopsoft.io/workshopsoft/ludic.git . + git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" + + - name: Lint the new commits + env: + BEFORE: ${{ github.event.before }} + BASE: ${{ github.base_ref }} + run: | + set -eu + # Pick the range of *new* commits to lint: + # - pull_request: base branch .. this commit + # - push: the pushed range (event.before .. this commit) + # - new branch / unknown: just the tip commit + if [ -n "${BASE:-}" ]; then + git fetch --quiet origin "${BASE}" 2>/dev/null || true + RANGE="origin/${BASE}..${GITHUB_SHA}" + elif [ -n "${BEFORE:-}" ] && ! printf '%s' "$BEFORE" | grep -qE '^0+$'; then + RANGE="${BEFORE}..${GITHUB_SHA}" + else + RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}" + fi + echo "linting range: $RANGE" + sh tools/git-hooks/lint-range.sh "$RANGE" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e914cf1..9c76d543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,11 +67,43 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces ## Conventions - **Commits:** [Conventional Commits](https://www.conventionalcommits.org) — - `type(scope): summary`, e.g. `feat(stdlib): …`, `fix(emit): …`, - `ci(docs): …`, `docs(readme): …`. Keep the summary imperative and under ~72 - chars. + `type(scope): summary`, with an optional `!` before the colon for a breaking + change. Keep the summary imperative and under ~72 chars. The types in use: + + | type | for | + |------|-----| + | `feat` | a new user-facing capability (a stdlib namespace, a language feature) | + | `fix` | a bug fix | + | `refactor` | a change that neither fixes a bug nor adds a feature | + | `perf` | a performance improvement | + | `docs` | documentation only (`docs/`, README, comments) | + | `test` | tests only | + | `build` | the build/bootstrap machinery (seed, `bin/x`, linking) | + | `ci` | CI workflows under `.forgejo/` | + | `style` | formatting/whitespace, no behaviour change | + | `chore` | routine housekeeping with no other bucket | + | `revert` | reverts a previous commit | + + Common scopes: `stdlib`, `lang`, `emit`, `runtime`, `tooling`, `docs`, `repo`. + Reference the issue you close with a `Closes #NN` trailer. + +- **Enforcement:** the hooks in [`tools/git-hooks/`](tools/git-hooks) enforce this + locally, and the `commit-lint` CI job is the backstop. Turn the hooks on once, + per clone: + + ```bash + git config core.hooksPath tools/git-hooks + ``` + + That activates the `commit-msg` hook (rejects a non-conforming summary) and the + `pre-commit` hook (rejects unformatted Ludic). Both read the same rules CI does, + so a green local commit is a green CI run. + - **Formatting:** `ludic-fmt` is the source of truth (2-space indent, LF, UTF-8); - the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt` on files you touch. + the repo `.editorconfig` mirrors it. Run `bin/ludic-fmt -w` on files you touch. + The contract CI enforces is *idempotence* — `ludic-fmt` re-run on its own output + is a no-op — which leaves deliberate hand alignment in place; it is not a + blanket `fmt(x) == x`. - **Code structure:** one job per file. Split large files by concern into subfolders rather than growing a single 500+-line module (see how `selfhost/` and `runtime/` are organised). @@ -80,6 +112,19 @@ The stdlib lives in the runtime (`runtime/`) and is surfaced as namespaces (`selfhost/ludicc.seed.ll`), the hand-written `runtime/native/cocoa.ll` / `runtime/web/wasm.ll` shims, and the legacy Python docgen (being ported). +## Git history + +The log has two eras: the pre-self-hosting `Phase Nx: …` / `Merge Phase Nx: …` +commits, and the Conventional Commits used since. **Decision: the old `Phase` +history stays as-is.** Rewriting already-pushed history (`filter-repo`/rebase) is +destructive and non-reversible for anyone who has cloned, and it buys little — so +we do not rewrite it. The convention is enforced *going forward* by the hook and +the `commit-lint` job (both skip merge commits, so the old merges never trip it). + +When the versioning work lands (issue #33), the first release tag doubles as a +clean `v0` baseline that brackets the `Phase`-era prefix — the safe, +non-destructive version of "tidy the history" without touching a single commit. + ## Pull requests - Base your branch on `main`. diff --git a/tools/git-hooks/commit-msg b/tools/git-hooks/commit-msg new file mode 100755 index 00000000..7f2650d6 --- /dev/null +++ b/tools/git-hooks/commit-msg @@ -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 diff --git a/tools/git-hooks/lib.sh b/tools/git-hooks/lib.sh new file mode 100644 index 00000000..45dec79d --- /dev/null +++ b/tools/git-hooks/lib.sh @@ -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 -> 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 — print a short, actionable explanation. +conventional_help() { + echo " commit summary is not a Conventional Commit:" + echo " ${1:-}" + 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" +} diff --git a/tools/git-hooks/lint-range.sh b/tools/git-hooks/lint-range.sh new file mode 100755 index 00000000..12706087 --- /dev/null +++ b/tools/git-hooks/lint-range.sh @@ -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 ] diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit index efe6657a..a0abf0b8 100755 --- a/tools/git-hooks/pre-commit +++ b/tools/git-hooks/pre-commit @@ -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