commit-sheriff
v3.0.1
Published
Shared husky commit-msg / pre-commit hooks enforcing ticket-based commit messages and branch naming
Maintainers
Readme
commit-sheriff
Shared Husky git hooks for any project — enforces a consistent branch naming format and commit message format (ticket + type, optionally module), and runs lint-staged / type-check / related tests before every commit.
Quick start
npm install --save-dev commit-sheriff husky
npx commit-sheriff initinit only sets up Husky, the git hooks, and .commitsheriffrc.json — it never installs
ESLint/Prettier/lint-staged on its own. Add those explicitly, if/when you want them:
npx commit-sheriff add-eslintThis is the full flow end to end. The Install, Usage, and What init does sections below repeat
these same commands with more context/explanation — you don't need to run anything extra from
there, they're not additional steps.
Table of contents
- Quick start
- Why
- Requirements
- Install
- Usage
- What
initdoes - Commit message format
- Branch name format
- Configuration (
.commitsheriffrc.json) - Pre-commit checks
- lint-staged
- Advanced: ESLint module-boundary template (TypeScript/React)
- Examples
- Updating
- Skipping / bypassing hooks
- Troubleshooting
- Releasing this package
- License
Why
Different repos tend to drift into different commit-message and branch-naming conventions, which makes changelogs, ticket tracing, and code review harder. commit-sheriff gives every project the same two git hooks (commit-msg, pre-commit) driven by one small root-level .commitsheriffrc.json config file, so:
- Every commit message references a ticket and a change type.
- Every branch name reflects the same ticket and change type.
- Lint/format/type-check/tests run automatically before a commit is allowed, but only for the tools the project actually has installed.
Requirements
- Node.js (used to read
package.jsonand evaluate the config — no runtime dependencies are installed bycommit-sheriffitself) - Git
huskyv9+ as a devDependency of your project
Install
npm install --save-dev commit-sheriff huskycommit-sheriff itself has no runtime dependencies of its own — installing it doesn't pull in
ESLint, Prettier, or lint-staged. Those are only installed if you explicitly run add-eslint
(see below).
Usage
npx commit-sheriff initRun this once per repo, from the repo root (where package.json lives). This works once commit-sheriff is installed as a devDependency (it exposes a commit-sheriff binary via node_modules/.bin), or directly without installing first via npx commit-sheriff init.
What init does
- Initializes Husky if it isn't already set up (runs
npx husky initwhen.husky/_/husky.shis missing). - Copies the hook scripts into
.husky/commit-msgand.husky/pre-commit(overwriting any existing files with those exact names) and makes them executable. - Creates a default
.commitsheriffrc.jsonfile at the repo root only if one doesn't already exist (and only if there's no legacypackage.json→commitGuardblock either — see Configuration) — re-runninginitnever overwrites your customized config. - Adds a
"prepare": "husky"npm script if missing, so hooks are (re)installed automatically afternpm install.
That's it — init doesn't touch ESLint, Prettier, or lint-staged, and it doesn't matter what's in
your dependencies (react+typescript or otherwise). If you want the lint/format step to actually
run something, add it explicitly — there's a single command for this:
npx commit-sheriff add-eslintSets up the TypeScript/React module-boundary
template end to end: writes
eslint.config.mjs, eslint.config.commit-sheriff.mjs, and .prettierrc.js (folding in
next/core-web-vitals/next/typescript automatically if next is detected), installs all the
plugins it needs, and adds the lint-staged config block to package.json.
Everything it writes is overwritten every time you run it — the lint-staged block included,
even if you'd customized it. This is intentional (same policy as the ESLint templates
themselves) — no manual merge step, ever. Back up your own customizations under a different name
first if you have any, then re-apply them after.
Re-running add-eslint later (e.g. after updating the package) refreshes all of the above to the
latest version. .commitsheriffrc.json itself is left alone once it exists.
Why the pre-commit hook checks for the package, not just the config: it only runs
lint-staged if it can actually find the package — if it's missing, older versions of this hook
silently skipped linting entirely, so a broken/incomplete setup looked identical to a working
one until someone noticed bad code slipping through. If package.json has a lint-staged config
block but the package isn't actually installed (e.g. node_modules got wiped, or someone added
the config by hand), the commit now fails loudly with instructions, instead of passing
silently.
Commit message format
(TICKET) type(scope): messageor, when useModules is true:
(TICKET) [MODULE] type(scope): messageTICKET—PROJECT-NUMBER, e.g.PROJ-1191(uppercase letters, a dash, digits)MODULE— required only ifuseModules: true(seemodules)type— one of the words configured intypes(scope)— optional, free text in parenthesesmessage— free text description
Merge/revert/fixup/squash commits (Merge ..., Revert ..., fixup! ..., squash! ...) are always allowed through unchanged — no need to reformat what Git itself generates.
Examples
(PROJ-1077) feat: implement organization management
(PROJ-1191) fix(register): reset registration session on menu reopen
(PROJ-1077) [SET] feat: implement organization management # only with useModules: trueIf the commit message doesn't match, the commit is rejected and the hook prints the required format, an example, the ticket pattern, and the allowed types (and modules, if enabled).
When useModules: true, the hook also checks that the [MODULE] in the commit message matches the module encoded in the current branch name (see below) — so you can't accidentally tag a commit for a different module than the branch you're on.
Branch name format
<type>/<PROJECT>-<NUMBER>-<description>or, when useModules: true:
<type>/<MODULE>-<PROJECT>-<NUMBER>-<description>type— one of the words configured inbranchTypesMODULE— required only ifuseModules: truePROJECT-NUMBER— same ticket reference as in commit messagesdescription— lowercase, starts with a letter, only lowercase letters/digits/dashes after that
Examples
bugfix/PROJ-1093-fix-validation
improvement/PROJ-609-correct-husky-pre-commit-validation
improvement/MOD-PROJ-609-correct-husky-pre-commit-validation # only with useModules: trueThe check is skipped on a detached HEAD (e.g. mid-rebase, mid-cherry-pick), so it never blocks those operations.
Configuration (.commitsheriffrc.json)
Add/edit this file at your project's root (next to package.json) — the init command creates a default one for you:
{
"useModules": false,
"project": "PROJ",
"modules": [],
"branchTypes": [
"feature",
"bugfix",
"hotfix",
"improvement",
"refactor",
"release",
"chore",
"docs",
"test",
"spike"
],
"types": [
"feat",
"fix",
"docs",
"style",
"refactor",
"test",
"chore",
"perf",
"ci",
"build",
"revert"
]
}All keys are optional; anything you omit falls back to the default shown above. Edit this file whenever you like — the hooks read it fresh on every commit, so changes take effect immediately, no reinstall or init re-run needed.
Legacy package.json → commitGuard (backward compatibility)
Versions of commit-sheriff before this config-file change stored the same settings under a commitGuard key in package.json instead. That still works: if no .commitsheriffrc.json file exists, the hooks (and the add-eslint template) fall back to reading package.json's commitGuard block automatically, so existing installs keep working without any changes required. New installs (and any project without an existing commitGuard block) get the new .commitsheriffrc.json file instead — this is the recommended location going forward. To migrate an existing project by hand, move the contents of commitGuard out into a new root-level .commitsheriffrc.json file and delete the commitGuard key from package.json.
useModules
boolean, default false.
Turns the [MODULE] tag on or off in both the commit message and the branch name. Leave this false unless your project is split into named modules/domains that you want tracked per commit.
project
string, default "PROJ".
The project key enforced in both the branch name (<type>/<PROJECT>-<NUMBER>-...) and the commit message ticket ((<PROJECT>-<NUMBER>) type: ...). Set this to your actual Jira/Linear/etc. project key — a ticket prefix that doesn't match (e.g. a typo, or a different project's key) is rejected by both hooks.
modules
string[], default: none — falls back to accepting any uppercase code ([A-Z]+).
Only relevant when useModules: true. If you want to restrict commits/branches to a specific, known set of module codes, list them explicitly:
{
"useModules": true,
"project": "PROJ",
"modules": ["AUTH", "BILLING", "REPORTS"]
}If you omit modules (or leave it as []), any uppercase code is accepted (e.g. [AUTH], [XYZ], [SET]) — useful early in a project before the final module list is settled.
branchTypes
string[], default: ["feature", "bugfix", "hotfix", "improvement", "refactor", "release", "chore", "docs", "test", "spike"].
Allowed prefixes for branch names. This is intentionally a coarser, workflow-level vocabulary (what kind of branch is this?) — it does not need to match types word-for-word, since a single feature/... branch will typically contain several kinds of commits (feat, test, docs, chore, ...) as the feature is built.
types
string[], default: ["feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "ci", "build", "revert"].
Allowed type words in commit messages, following Conventional Commits style.
Pre-commit checks
In addition to the branch name check, pre-commit conditionally runs (skipped when the project doesn't have the relevant tool):
npm run type-check— if atype-checkscript exists inpackage.jsonnpx lint-staged— ifpackage.jsonhas alint-stagedconfig block. If the block exists but thelint-stagedpackage itself isn't actually installed, the commit fails with instructions rather than silently skipping the check (this used to silently no-op — see Whatinitdoes).npx vitest related --run --passWithNoTests <staged .ts/.tsx files>— ifvitestis listed as a dependency and staged.ts/.tsxfiles exist (bounded to 60s viatimeout/gtimeoutwhen available)
Any failure here aborts the commit.
lint-staged
The default config added by add-eslint (overwritten every run — see above):
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,scss,html}": ["prettier --write"]
}
}Warnings are printed but never block the commit — only actual errors (ESLint's non-zero exit) do.
That's the default ESLint behavior (--fix alone exits 0 on warning-only results), so a rule set to
"warn" (e.g. an unused import under some "recommended" configs) shows up in the terminal as a
heads-up but doesn't stop you from committing.
Adjust freely, but remember add-eslint overwrites this block on every run (back it up under
a different key first if you've customized it). If you want warnings to block the commit too, add
--max-warnings=0 to the eslint --fix command by hand.
Advanced: ESLint module-boundary template (TypeScript/React)
Optional, separate from init — for projects that split feature code into modules (e.g.
src/app/auth/, src/app/billing/) and want ESLint to enforce that modules only talk to each
other through a public barrel file, never through deep/internal paths:
npx commit-sheriff add-eslintThis ships as a flat config (eslint.config.*, ESLint 9+ format), not the legacy
.eslintrc.js format — see why below.
It writes/refreshes three files every run, always overwriting whatever was there before (same
as the commit-msg/pre-commit hooks) — no manual merge step, ever. If you've hand-edited any of
these, back them up under a different name first:
| File | What it is |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| eslint.config.commit-sheriff.mjs | The actual ruleset: module-boundary rules + all the recommended plugins (TypeScript, React, hooks, a11y, sonarjs, import, testing-library, vitest, jest, prettier). |
| eslint.config.mjs | The entry point ESLint actually reads. Just imports the file above — or, if next is detected in your dependencies, also folds in Next.js's own ESLint rules so you don't lose Next-specific linting (eslint-config-next is auto-installed if missing). |
| .prettierrc.js | Matching Prettier config for the same ruleset. |
If your project already had its own eslint.config.mjs (e.g. Next.js's own
create-next-app-generated one), it gets replaced by the one above, not merged with it.
Next.js support is version-aware: eslint-config-next changed its internal shape between major
versions (a native flat-config array from v16 on, the older eslintrc-style object before that), and
the generated eslint.config.mjs detects which one is actually installed and uses the matching
pattern automatically — you don't need to think about this at all, it's handled for you.
The module list for the boundary rule isn't hardcoded — it's read straight from the modules
array in your root .commitsheriffrc.json (the same list the commit-msg/branch-name hooks use
for the [MODULE] tag), lowercased. For older installs still using package.json's
commitGuard.modules, that's used as a fallback. If neither is set, a small illustrative default
(auth, billing, reports, settings) is used — open the generated config and replace it, or
just fill in modules in .commitsheriffrc.json and it picks it up automatically.
This template assumes a src/app/<module>/... folder layout with <module>.public.ts barrel
files; adjust the paths inside the generated config if your structure differs.
It installs whatever it needs automatically (via npm install --save-dev), skipping anything
already present:
npm install --save-dev eslint@^9 typescript@^5 @typescript-eslint/parser \
@typescript-eslint/eslint-plugin @eslint/js@^9 @eslint/eslintrc eslint-plugin-sonarjs \
eslint-plugin-import eslint-plugin-prettier eslint-config-prettier eslint-plugin-react \
eslint-plugin-react-hooks eslint-plugin-jsx-a11y eslint-plugin-testing-library \
@vitest/eslint-plugin eslint-plugin-jest prettiereslint, @eslint/js, and typescript are pinned on purpose rather than left to resolve to
whatever latest is — the plugin ecosystem here regularly lags behind both. Two concrete
conflicts this has already caused: @eslint/js's own latest declares a hard peer on
eslint@^10, so leaving it unpinned next to eslint@^9 is an instant ERESOLVE; and
@typescript-eslint/eslint-plugin currently only supports typescript up to <6.1.0, so an
unpinned typescript install (which resolves to its own latest, e.g. 7.x) crashes plugin
loading with "typescript-eslint does not support TS 7.0". Pinning eslint/@eslint/js together
to ^9 and typescript to ^5 keeps the whole toolchain on versions that are actually
compatible with each other today.
If the install fails (offline, registry issue, etc.), it prints this exact command so you can run it yourself.
Why flat config?
ESLint 9+ looks for eslint.config.* first and, if one is found, ignores .eslintrc.js
entirely — there's no fallback. A legacy-only .eslintrc.js template sitting next to a
framework-generated eslint.config.mjs (Next.js scaffolds one by default) would simply never run —
this used to be a real bug here, where an unused import went completely unflagged because the
module-boundary rules were silently dead. Flat config avoids that trap entirely.
A legacy .eslintrc.js version of this template (eslintrc.module-boundaries.js) still ships
inside the package for reference/manual use on pure ESLint 8 projects with no flat config support,
but the CLI no longer writes it by default.
Examples
Minimal project (no modules) — .commitsheriffrc.json:
{
"useModules": false,
"project": "PROJ",
"branchTypes": [
"feature",
"bugfix",
"hotfix",
"improvement",
"refactor",
"release",
"chore",
"docs",
"test",
"spike"
],
"types": ["feat", "fix", "docs", "chore", "test"]
}git checkout -b bugfix/PROJ-1093-fix-validation
git commit -m "(PROJ-1093) fix: correct validation on empty input"Project split into modules, with a locked list — .commitsheriffrc.json:
{
"useModules": true,
"project": "ACME",
"modules": ["AUTH", "BILLING", "REPORTS"],
"branchTypes": [
"feature",
"bugfix",
"hotfix",
"improvement",
"refactor",
"release",
"chore",
"docs",
"test",
"spike"
],
"types": ["feat", "fix", "docs", "chore", "test"]
}git checkout -b feature/AUTH-ACME-42-add-sso
git commit -m "(ACME-42) [AUTH] feat: add SSO login"Updating
When a new version of commit-sheriff is published:
npm install commit-sheriff@latest --save-dev
npx commit-sheriff initnpm install updates the package; npx commit-sheriff init re-copies the (possibly changed) .husky/commit-msg and .husky/pre-commit scripts. It will not touch your existing .commitsheriffrc.json (or legacy commitGuard in package.json) or lint-staged config.
If you're using add-eslint, re-run that command too — it's the one that refreshes the generated ESLint/Prettier files and the lint-staged block (init never touches them).
Skipping / bypassing hooks
Not recommended as a habit, but for emergencies Git supports:
git commit --no-verify -m "..."Merge, revert, fixup!, and squash! commits are already exempted from the commit-message format check automatically.
Troubleshooting
"Could not load commitGuard config — is Node.js installed and is this running from the repo root?"
The hook runs node -e "..." against ./.commitsheriffrc.json (falling back to ./package.json's commitGuard block for older installs). Make sure Node.js is on your PATH and that you're committing from the repository root (or that your Git client runs hooks with the repo root as the working directory — some GUI clients get this wrong).
A commit is accepted even though the message looks wrong
Check which branch/tool you actually committed from — hooks only run for the local Git client that has Husky's core.hooksPath configured (git config core.hooksPath should print .husky/_). GUI clients or CI systems that bypass local hooks (or commit via the GitHub/GitLab API) will not trigger them.
Branch name rejected right after git checkout -b ...
Detached HEAD is exempt, but a normal new branch is checked immediately on first commit — rename it with git branch -m <valid-name> and try again.
useModules: true but every module code is accepted
That's expected if modules is empty/omitted in .commitsheriffrc.json — see modules. Add an explicit list to restrict it.
Releasing this package
npm version patch # or minor / major
npm publish(npm version runs on a properly named branch per this repo's own hooks, then fast-forward it into main.)
License
ISC
