Skip to content

Git Hooks

Ferran Buireu edited this page Oct 18, 2026 · 23 revisions

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/.


Installation

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 it

If the hooks ever go missing (say, after re-cloning without installing), pnpm exec lefthook install re-wires them.


How the config is composed

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)"]
Loading

pre-commit

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.


commit-msg

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

Nothing here blocks a cross-package commit

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.

commitlint

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-web and contribkit-app (not web and app, which commitlint rejects) plus global, for a change that belongs to neither client. The scope is optional.
  • scope-case: scopes may be lower-case, PascalCase, or camelCase.
  • 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 a BREAKING CHANGES line longer than body-max-line-length would otherwise fail the release commit. That happens in semantic-release's prepare step, 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.


pre-push

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.


sync-shared-assets.mjs

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 in app/assets/. See Project Structure.


Bypassing hooks

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.


See also

  • CI/CD enforces the same checks server-side, plus releases.
  • Project Structure covers the monorepo tooling and shared tokens.

Clone this wiki locally