# code-hooks **Repository Path**: mose-x/code-hooks ## Basic Information - **Project Name**: code-hooks - **Description**: code hooks for all - **Primary Language**: Shell - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-07-28 - **Last Updated**: 2026-08-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # code-hooks Public repo hosting shared git hooks for any repo that adopts them. The hooks themselves (`pre-commit`, `commit-msg`, `pre-push`) are never committed into the consumer repo; instead each workdir points at this repo via `core.hooksPath`. A bootstrap script (`setup-code-hooks.sh`) clones this repo and wires up the workdir in one shot. ## Quick start Three steps, ~2 minutes: ```bash # 1. Install local hooks into your repo (idempotent, safe to re-run) git clone https://github.com/mose-x/code-hooks.git "$HOME/.code-hooks" bash "$HOME/.code-hooks/setup-code-hooks.sh" /path/to/your-repo # 2. Set your git identity to an email listed in hook-rules.conf [identities] cd /path/to/your-repo git config user.email "your@email" # 3. Wire CI (one line in .github/workflows/ci.yml): # jobs: # commit-lint: # uses: mose-x/code-hooks/.github/workflows/commit-lint.yml@main # Then set branch protection to require the "commit-lint / commit-lint" # status check on your protected branch. ``` After step 1, every `git commit` / `git push` in your repo runs the hooks locally. After step 3, CI enforces the same rules on PRs (the layer that `--no-verify` cannot bypass). ## Hooks | Hook | Enforces | |---|---| | `pre-commit` | author + committer email must be in `hook-rules.conf [identities]`; per-language `fmt` + `lint` from `[lang_tools]` when source files of that language are staged | | `commit-msg` | subject <= `[rules] max_subject_length`; no trailing `.`; conventional-commits type from `[commit_types]`; no `[forbidden_tokens]` (incl. variants); pure ASCII; total message <= `[rules] max_total_message_length` | | `pre-push` | only branches in `[allowed_branches]`; annotated tag tagger email in `[identities]` + tag message ASCII + token-clean; lightweight tag's commit author/committer email in `[identities]`; every pushed commit's author AND committer email must be in `[identities]`; no forbidden tokens in push range; per-language `test` from `[lang_tools]` when source files of that language changed in the push range | ### Why each rule exists - **Identity allowlist (pre-commit + pre-push + CI)**: enforces that every commit is authored and committed by a known contributors. `git config user.email` only controls the *author*; `GIT_COMMITTER_EMAIL` can override the committer without touching config, creating a split identity on GitHub (author=listed, committer=other). This already leaked `noreply` commits into history once. pre-commit catches it at commit time; pre-push catches commits made with `--no-verify` or before the hook existed; CI catches anything that slipped past both and is the only layer that cannot be bypassed. - **Conventional-commits prefix**: the repo's history is 100% prefixed; enforcing it prevents accidental `update something`-style subjects. - **Forbidden-token variants**: `trae agent` / `co-authored by` / `generated by trae` are configured as literal entries in `[forbidden_tokens]`. The matcher normalizes non-alphanumeric characters to a single space and lowercases before substring match, so `trae-agent`, `traeagent`, `Co-Authored-By`, etc. all hit the same entry. - **Language tools gated on file suffix**: docs/hook-only commits skip every fmt/lint/test suite; only real source changes pay for toolchain invocation. Detection is by staged/changed file suffix, not by manifest existence, so a README-only commit on a Rust project still skips cargo. **Five languages are configured out-of-box**: Rust, Go, Node.js (React/Vue/Svelte/...), Python, Java. The detector also recognises PHP, Perl, C# suffixes -- these become active the moment a consumer adds the corresponding `[lang_tools]` entries (no code change needed; see [Adding or customizing a language](#adding-or-customizing-a-language)). wails projects are detected as `go` + `nodejs`. ## Configuration: `hook-rules.conf` All adjustable rules live in a single plain-text file, `hook-rules.conf`, formatted as ini-style sections. This is the single source of truth read at runtime by all three layers (`pre-commit`, `pre-push`, CI `commit-lint`). ```ini [identities] mose-zm <602187256@qq.com> alice [allowed_branches] dev dev-* feature/* [forbidden_tokens] trae agent traeagent co-authored by coauthored by generated by trae [commit_types] feat fix refactor perf test style docs ci chore build revert [rules] max_subject_length=100 max_total_message_length=200 [lang_tools] # Format: lang:stage=command ($FILES = staged/changed files) # Five languages configured out-of-box; add more (php, perl, csharp, ...) # by following "Adding or customizing a language" below. rust:fmt=cargo fmt --check rust:lint=cargo clippy --all-targets -- -D warnings rust:test=cargo test --all go:fmt=gofmt -l $FILES go:lint=go vet ./... go:test=go test ./... nodejs:fmt=npx prettier --check $FILES nodejs:lint=npx eslint $FILES nodejs:test=npm test python:fmt=ruff format --check . python:lint=ruff check . python:test=pytest java:test=mvn test # leave a stage empty to disable it for a language ``` ### Section reference | Section | Format | Used by | |---|---|---| | `[identities]` | `Display Name ` one per line | pre-commit, pre-push, commit-lint CI | | `[allowed_branches]` | literal branch name or `prefix*` per line | pre-push | | `[forbidden_tokens]` | literal token per line; normalized match | pre-push, commit-msg | | `[commit_types]` | one conventional-commit type per line | commit-msg | | `[rules]` | `key=value` scalar rules | commit-msg | | `[lang_tools]` | `lang:stage=command` per line | pre-commit (fmt, lint), pre-push (test) | ### Section rules - `#` starts a comment; blank lines are ignored. - Lines starting with `[` are section headers. Section order is not significant. - `[rules]` uses `key=value`; `[lang_tools]` uses `lang:stage=command`; other sections are list-of-entries. - **Only the email is enforced** for `[identities]` -- the name is informational and not checked, so a contributor's local `user.name` may differ from the file. ### Language tools (`[lang_tools]`) Per-language fmt / lint / test commands. Format: `lang:stage=command`. - **`lang`** -- any language key. Out-of-box: `{rust, go, nodejs, python, java}`. The detector also recognises `{php, perl, csharp}`; add entries for them to enable (see [Adding or customizing a language](#adding-or-customizing-a-language)). - **`stage`** in `{fmt, lint, test}`. `fmt` and `lint` run in pre-commit; `test` runs in pre-push. - **`$FILES`** is substituted with the staged/changed file list (space- separated, auto-quoted) so fmt/lint target only touched files. Commands that ignore `$FILES` (e.g. `cargo fmt --check`, `pytest`) scan the whole repo -- fine for most toolchains. - **`$FILES` must be used bare** -- do not wrap it in quotes (e.g. `prettier --check "$FILES"`). Each path is already single-quoted via `printf %q`, so the replacement expands to `'path1' 'path2' ...`; an outer pair of quotes would break that into one literal argument. - **Empty value** (`lang:stage=`) disables that stage for that language (e.g. Java fmt/lint are intentionally empty: per-project style). - **Missing section** -> all language tools skipped (docs-only fast path; fresh clones of code-hooks itself also skip). - **Tool not in PATH** -> fail closed with an install hint. This matches the identity fail-closed philosophy: if your commit touches `.go` you must have `go` installed locally, otherwise local and CI diverge. `cargo` / `npx` / `npm` / `mvn` / `gradle` are treated as always- available (they self-resolve subcommands). Supported languages and default commands: **Out-of-box (entries shipped in `hook-rules.conf`):** | Language | Detected by | fmt | lint | test | |---|---|---|---|---| | Rust | `.rs` | `cargo fmt --check` | `cargo clippy --all-targets -- -D warnings` | `cargo test --all` | | Go | `.go` | `gofmt -l $FILES` | `go vet ./...` | `go test ./...` | | Node.js | `.js .ts .jsx .tsx .mjs .cjs .vue .svelte .astro` | `npx prettier --check $FILES` | `npx eslint $FILES` | `npm test` | | Python | `.py` | `ruff format --check .` | `ruff check .` | `pytest` | | Java | `.java .kt` | (empty) | (empty) | `mvn test` | **Recognised but not configured** (suffix detected; add `[lang_tools]` entries to enable -- no code change needed): | Language | Detected by | Suggested fmt | Suggested lint | Suggested test | |---|---|---|---|---| | PHP | `.php` | `./vendor/bin/php-cs-fixer fix --dry-run` | `./vendor/bin/phpstan analyse` | `./vendor/bin/phpunit` | | Perl | `.pl .pm .t` | (none) | `perlcritic $FILES` | `prove -r t` | | C# | `.cs` | `dotnet format --verify-no-changes` | `dotnet build -warnaserror` | `dotnet test` | **wails projects**: a wails app has Go backend + JS/TS frontend under `frontend/`. It is detected as `go` + `nodejs` and both toolchains run. `wails.json` is not required for detection -- it only affects which nodejs commands are configured in this section. **Vue / React / Svelte / etc.**: these are all Node.js -- they share the same `nodejs` entry. The framework-specific lint plugin (eslint-plugin-vue, eslint-plugin-react, ...) is picked up from the consumer repo's eslint config, so code-hooks does not need a separate entry per framework. #### Adding or customizing a language To change a tool command for an existing language, edit the `lang:stage=command` line in `[lang_tools]`. No code change needed. To enable a **recognised-but-unconfigured** language (PHP / Perl / C#), just add `[lang_tools]` entries -- the detector already knows those suffixes, so **no code change is needed**: ```ini php:fmt=./vendor/bin/php-cs-fixer fix --dry-run php:lint=./vendor/bin/phpstan analyse php:test=./vendor/bin/phpunit ``` Commit the config change; after merge, every consumer repo that touches `.php` files will run the PHP toolchain automatically. To add a **brand-new language** whose suffix is not yet recognised (e.g. Ruby `.rb`): 1. **Add the suffix to `lang-detector.sh`** so files are detected. This is the only code change required: ```bash printf '%s\n' "$files" | grep -qE '\.rb$' && langs+=("ruby") ``` 2. **Add `[lang_tools]` entries** for the new language in `hook-rules.conf`: ```ini ruby:fmt=rubocop -x --auto-correct ruby:lint=rubocop ruby:test=rspec ``` 3. Commit both changes in the same PR. After merge, every consumer repo that touches `.rb` files will run the Ruby toolchain automatically. Note: step 1 is required because language detection is by file suffix, not by reading `[lang_tools]` keys. This keeps detection cheap (one grep per language) and decouples "which files trigger tools" from "which commands run". ### Branch matching (`[allowed_branches]`) - **Exact match**: a literal branch name (e.g. `dev`) matches only `dev`. - **Prefix match**: an entry ending in `*` matches any branch that starts with the prefix before `*`. For example `dev-*` matches `dev-hotfix` but not `dev` itself; `dev*` matches `dev`, `develop`, `dev/foo`, etc. - Any other glob character (`?`, `[...]`, a `*` that is not at the end) is treated as a literal character. - Empty section -> all pushes rejected (fail-closed). ### Forbidden-token matching (`[forbidden_tokens]`) - Tokens are matched as literals, case-insensitive. - Before matching, the message is normalized: every run of non-alphanumeric characters is replaced with a single space, and the result is lowercased. This makes `trae agent` / `trae-agent` / `TRAE AGENT` all hit the entry `trae agent`. The no-separator variant `traeagent` is listed separately because normalization keeps it as one word. - Match is word-bounded (the token must appear surrounded by whitespace or message boundaries) to avoid false positives on substrings. - Empty section -> all commits rejected (fail-closed). ### Tag push handling `pre-push` distinguishes tag pushes from branch pushes: - Tag pushes (`refs/tags/*`) skip the branch allowlist and the push-range forbidden-token / identity scans (tags are not branch commits). - **Annotated tags**: the tagger email must be in `[identities]`, and the tag message must be ASCII-only English and must not contain any `[forbidden_tokens]` entry. Length and conventional-prefix rules are not applied (tags do not follow commit conventions). - **Lightweight tags**: no tagger field and no message, but the commit they point to must have author AND committer email in `[identities]` -- so a lightweight tag cannot bless an unverified commit (e.g. one made with `--no-verify` and a stray `user.email`). - Tag pushes do not trigger language `test` suites (tags change metadata, not source). - **CI also validates annotated tags** in the PR range: the `commit-lint` job scans any annotated tag whose target commit falls within `BASE..HEAD`, checking tagger email, ASCII-only message, and forbidden tokens. This covers the `--no-verify` bypass path for tags pushed via PR. (Tags pushed directly to the remote without a PR are still GitHub-side only -- configure tag protection rules there.) ### Single source of truth (no local override) `hook-rules.conf` is read exclusively from `$HOOKS_DIR/hook-rules.conf` -- the file committed to this repo. There is **no local override mechanism**: a contributor cannot drop a custom file next to the hooks to bypass any check. The repo owner solely controls who can commit and which rules apply by editing this one file. This is intentional: even though CI is the ultimate enforcement layer (it reads the file from GitHub, which a contributor cannot edit without a merged PR), removing the local override keeps the model simple -- one file, one owner, no surprise bypass paths on developer machines. ### Fail-closed behaviour If `hook-rules.conf` is missing, or any required section (`[identities]`, `[allowed_branches]`, `[forbidden_tokens]`, `[commit_types]`) is missing or empty, **the corresponding hook rejects every commit/push**. This is intentional: a fresh clone of code-hooks with no rules configured must not silently run with enforcement disabled. Fix it by populating the section. `[lang_tools]` is **not** fail-closed: a missing or empty section simply skips all language fmt/lint/test suites (docs-only fast path). This is so a fresh clone of code-hooks itself -- which has no Rust/Go/... source to test -- does not reject its own commits. Per-language tool-missing is still fail-closed (a `.go` commit with no `go` binary is rejected). ### Adding a new contributor **All contributor identities are managed solely in `code-hooks`'s `hook-rules.conf [identities]` -- consumer repos never edit identity configuration.** A single allowlist is shared by every consumer repo's pre-commit, pre-push, and CI. 1. Add a line `Their Name ` to the `[identities]` section of `hook-rules.conf` in the `code-hooks` repo. 2. Commit and push to `code-hooks` (the contributor's PRs to consumer repos will start passing CI once `code-hooks@main` is updated). 3. The contributor runs `git config user.email their@email` in their local consumer-repo checkout so pre-commit passes locally. (CI will pass too, since the email is now in the allowlist on GitHub.) ## Install local hooks Two install paths depending on your environment. After install, every `git commit` / `git push` in the target repo runs the hooks locally. ### A. Standard setup (any machine: macOS / Linux / CI runner) ```bash # Clone code-hooks to a stable location (default: $HOME/.code-hooks; # override with CODE_HOOKS_DIR=/custom/path) git clone https://github.com/mose-x/code-hooks.git "$HOME/.code-hooks" # Install hooks into your repo (idempotent, safe to re-run) bash "$HOME/.code-hooks/setup-code-hooks.sh" /path/to/your-repo # Set your git identity to an email listed in hook-rules.conf [identities] cd /path/to/your-repo git config user.email "your@email" ``` `setup-code-hooks.sh` sets `core.hooksPath` on the target repo, pulls `code-hooks` to the latest `main` on each run, and pins the first identity from `[identities]` into the repo's `user.name` / `user.email` so pre-commit passes out-of-box. If your email is NOT the first entry, set it manually with `git config user.email`. ### B. Sandbox setup (ephemeral TRAE / CI environments) The sandbox network allows `git clone` from `github.com` (but blocks `raw.githubusercontent.com`). `$HOME` may be `/root` or reset between sessions, so pin the clone path explicitly: ```bash git clone https://github.com/mose-x/code-hooks.git /root/.code-hooks && \ bash /root/.code-hooks/setup-code-hooks.sh /workspace ``` Replace `/workspace` with the actual target workdir path. If the sandbox resets `$HOME`, re-run the two commands to restore hooks -- the script is idempotent. ### Language toolchain requirements The hooks only invoke a language's toolchain when files of that language are staged/changed. A docs-only commit skips all tools. When a tool is missing from `PATH` but its language is triggered, the hook **fails closed** with an install hint (so local and CI do not diverge). | Language | Detected by | fmt requires | lint requires | test requires | |---|---|---|---|---| | Rust | `.rs` | `cargo` (rustup) | `cargo` (rustup) | `cargo` (rustup) | | Go | `.go` | `gofmt` (bundled with Go) | `go` (Go toolchain) | `go` (Go toolchain) | | Node.js | `.js .ts .jsx .tsx .mjs .cjs .vue .svelte .astro` | `npx` (Node.js + npm) | `npx` (Node.js + npm) | `npm` (Node.js) | | Python | `.py` | `ruff` (`pip install ruff`) | `ruff` (`pip install ruff`) | `pytest` (`pip install pytest`) | | Java | `.java .kt` | (disabled) | (disabled) | `mvn` (Maven) | For PHP / Perl / C# requirements, see the suggested commands in the [language table above](#language-tools-lang_tools) -- they become active once you add the corresponding `[lang_tools]` entries. `cargo` / `npx` / `npm` / `mvn` are treated as always-available (they self-resolve subcommands); other binaries are checked explicitly. ## CI enforcement (reusable workflow) Local hooks can be bypassed with `git commit --no-verify`. The real enforcement is the `commit-lint` job in CI, which re-runs the `commit-msg` script and the identity check from `pre-push` against every commit in a PR. This repo ships it as a [reusable workflow](https://docs.github.com/en/actions/using-workflows/reusing-workflows) so consumer repos adopt it with one line instead of copy-pasting ~60 lines of clone + scan logic. ### Adopt in a consumer repo This repo is **public**, so the reusable workflow clones it over unauthenticated HTTPS. Consumer repos need **no secrets, no deploy key, no PAT** -- adoption is two steps: 1. **Call the workflow** from the consumer repo's CI (e.g. `.github/workflows/ci.yml`): ```yaml jobs: commit-lint: uses: mose-x/code-hooks/.github/workflows/commit-lint.yml@main ``` No `secrets: inherit` needed -- the called workflow declares no secrets. 2. **Set branch protection** on the consumer repo's protected branch (Settings -> Branches -> Edit) to require the `commit-lint / commit-lint` status check. This is the only step that actually *enforces* -- without it, a failing `commit-lint` job is advisory and the PR can still merge. > **Visibility constraint**: GitHub only lets a caller repo reference a > reusable workflow from a repo it can read. Because this repo is public, > both public and private consumer repos can use it. (If this repo were > private, only private consumers in the same org could use it.) > **Required check name**: the reusable workflow's job reports under > `commit-lint / commit-lint` (caller job name `/` callee job name). Branch > protection's "Require status checks" must list exactly that string, not > just `commit-lint`, or the merge button stays red even with a green CI. ### Why three layers | Layer | Where | Can bypass? | |---|---|---| | Local hooks (`commit-msg`/`pre-push`) | developer machine | yes, `--no-verify` | | CI `commit-lint` job | GitHub Actions, on PR | only if not required | | Branch protection (required check) | GitHub server-side | **no** | All three layers read the same rules from `hook-rules.conf` in this repo, so a rule fix (e.g. a new `[forbidden_tokens]` entry or a branch policy update) propagates to every consumer repo without each repo touching its CI. ## Notes - `~/.git-credentials` (the GitHub PAT) and `/root/.code-hooks/` live under `/root` and do not persist across sandbox resets; re-run the [Sandbox setup](#b-sandbox-setup-ephemeral-trae--ci-environments) commands at the start of each new session. - The hooks repo itself is pushed using the same PAT / identity as the consumer repos. - `--no-verify` bypasses local hooks entirely (git design). The pre-push email scan is the last local checkpoint; for true enforcement, add GitHub branch protection + required status checks on the remote. - `hook-rules.conf` is readable by everyone (this repo is public). Don't put anything sensitive in the `Name` field -- it's just a display label.