Repository navigation
Git Hooks
ContribKit uses lefthook to run formatting, linting, analysis, and commit-message checks locally before code ever reaches CI. The config is split per component and composed at the root, so the same hooks work whether you touch web/, app/, or shared/.
lefthook is a root devDependency and the root prepare script runs lefthook install, so pnpm install wires up the Git hooks on every fresh clone: there is nothing to install by hand. The build script is allow-listed in pnpm-workspace.yaml (allowBuilds.lefthook), which the binary download needs.
pnpm install # hooks are wired as part of itIf the hooks ever go missing (say, after re-cloning without installing), pnpm exec lefthook install re-wires them.
The root lefthook.yml extends the two component configs and adds one repo-wide hook:
extends:
- app/lefthook.yml
- web/lefthook.yml
pre-commit:
commands:
sync-shared-assets:
glob: "shared/*.json"
run: node scripts/sync-shared-assets.mjs --stage
repo-format:
glob: "{docs,scripts,.github}/*.{ts,mjs,json}"
run: web/node_modules/.bin/biome check --write --no-errors-on-unmatched --config-path=web/biome.json {staged_files}
stage_fixed: true
commit-msg:
parallel: false
skip:
- rebase
- merge
commands:
commitlint:
run: pnpm exec commitlint --edit {1}lefthook merges the pre-commit and pre-push stages from every file, so a single commit runs every applicable command; commit-msg is declared once, at the root, because the message is a property of the repository rather than of either package.
---
config:
look: handDrawn
layout: dagre
---
flowchart TD
commit(["git commit"]) --> pc["pre-commit"]
pc --> sync["sync-shared-assets (shared/*.json)"]
pc --> webfmt["web-format: biome check --write"]
pc --> repofmt["repo-format: biome check --write"]
pc --> dartfmt["flutter-format: dart format"]
pc --> analyze["dart-analyze: dart analyze --fatal-infos"]
sync & webfmt & repofmt & dartfmt & analyze --> cm["commit-msg"]
cm --> lint["commitlint --edit"]
lint --> done(["commit created"])
done --> push(["git push"]) --> pp["pre-push"]
pp --> webcheck["web-verify: pnpm verify:changed"]
pp --> dartcheck["dart-analyze --fatal-infos (only if a .dart file, pubspec.yaml or analysis_options.yaml is pushed)"]
pp --> fluttertest["flutter-test + coverage floor (only if a .dart file, pubspec.yaml or analysis_options.yaml is pushed)"]
Runs on staged files only, before the commit is created.
| Command | Scope (glob) | Runs | Notes |
|---|---|---|---|
sync-shared-assets |
shared/*.json |
node scripts/sync-shared-assets.mjs --stage |
Regenerates app/assets/*.json from shared/ and re-stages them |
web-format |
web/**/*.{ts,astro,css,json} |
biome check --write on staged files |
stage_fixed: true, so fixes are auto-restaged |
repo-format |
{docs,scripts,.github}/*.{ts,mjs,json} |
biome check --write on staged files, with web/biome.json
|
the rest of what format:check reads; lefthook's * crosses directories, so the glob reaches nested files; stage_fixed: true
|
flutter-format |
app/**/*.dart |
dart format on staged files |
runs in parallel; stage_fixed: true
|
dart-analyze |
app/**/*.dart |
dart analyze --fatal-infos |
runs in parallel; fails on any info/warning |
Because web-format, repo-format and flutter-format use stage_fixed: true, autofixes are folded back into the same commit, so you don't need to re-git add them.
Validates the commit message. Runs sequentially (parallel: false) and is skipped during rebase and merge so you can rebase/merge without re-validating historical messages.
| Command | Runs | Purpose |
|---|---|---|
commitlint |
commitlint --edit |
Enforces Conventional Commits |
semantic-release-monorepo lists a commit in every package changelog whose files it touched, so a change spanning web/ and app/ appears in both. An auto-scope hook used to abort such a commit here. It was removed: main takes squash merges, so splitting locally produces one squashed commit touching both anyway, and ten commits on main touch both packages despite the hook having run on every one of them.
The check lives in CI now, on the pull request's combined diff, where the squash is actually visible. It comments and does not block, because a change that genuinely spans both clients is legitimate and releasing both is then correct. See CI/CD and ADR 0001.
Configured in commitlint.config.ts:
import type { UserConfig } from "@commitlint/types";
const RELEASE_COMMIT = /^chore\([a-z0-9-]+\): release \d+\.\d+\.\d+ \[skip ci\]/;
const configuration: UserConfig = {
extends: ["@commitlint/config-conventional", "@commitlint/config-pnpm-scopes"],
formatter: "@commitlint/format",
ignores: [(commit) => RELEASE_COMMIT.test(commit)],
rules: {
"scope-case": [2, "always", ["lower-case", "pascal-case", "camel-case"]],
"header-max-length": [2, "always", 130],
},
};
export default configuration;-
Conventional Commits:
type(scope): subject(e.g.feat(contribkit-web): add hex shape). -
pnpm scopes: valid scopes are derived from the workspace package names, so they are
contribkit-webandcontribkit-app(notwebandapp, which commitlint rejects) plusglobal, for a change that belongs to neither client. The scope is optional. -
scope-case: scopes may be
lower-case,PascalCase, orcamelCase. - header-max-length: the header may be up to 130 characters.
-
ignores: the release commit,
chore(<package>): release <version> [skip ci], is let through whatever its notes say: semantic-release writes the notes, and aBREAKING CHANGESline longer thanbody-max-line-lengthwould otherwise fail the release commit. That happens in semantic-release'spreparestep, before it creates the tag or the GitHub release, so the release would fail with nothing published and wait for the next run.
Conventional Commit types drive semantic-release versioning; see CI/CD.
Runs heavier checks before pushing, so a broken branch never reaches the remote.
| Command | Runs on | Runs |
|---|---|---|
web-verify |
every push |
pnpm verify:changed (format check, typecheck, astro check, the unit tests touched since origin/main, and the whole docs test, which the import graph never selects; no coverage floor, which CI enforces) |
dart-analyze |
a pushed *.dart, pubspec.yaml or analysis_options.yaml
|
dart analyze --fatal-infos |
flutter-test |
the same three |
flutter test --coverage, then dart run tool/check_coverage.dart
|
dart-analyze carries a glob and web-verify deliberately does not. Without one it ran on every
push, so a change to a workflow file or a markdown page paid for a full Flutter analysis that could not
possibly be affected by it. web-verify has no equivalent filter: the web set covers docs/, shared/,
scripts/, the root manifests and .github/ as well as web/, so filtering it would need a copy of ci.yml's WEB_PATHS, and a second copy of that filter is
the mistake this repository has already made three times.
The Flutter app can only bundle assets inside its own package, so the shared design tokens are mirrored from shared/*.json into app/assets/*.json. The script copies every JSON file across and, with --stage, re-stages the copies so they ride along in the same commit.
| When | Trigger |
|---|---|
| On commit | lefthook pre-commit, when a shared/*.json is staged (--stage) |
| In CI | never: release-app.yml copies shared/*.json into app/assets/ itself before the release build |
| Manually | pnpm sync:assets |
Always edit the source files in
shared/, never the generated copies inapp/assets/. See Project Structure.
In a genuine emergency you can skip hooks with git commit --no-verify / git push --no-verify, but the same checks run in CI/CD, so a bypass only defers the failure. Prefer fixing the issue locally.
- CI/CD enforces the same checks server-side, plus releases.
- Project Structure covers the monorepo tooling and shared tokens.