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

View file

@ -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`.