diff --git a/prek.toml b/prek.toml index 33cdded..ea06e70 100644 --- a/prek.toml +++ b/prek.toml @@ -1,7 +1,7 @@ #:schema https://www.schemastore.org/prek.json default_install_hook_types = ["pre-commit", "commit-msg"] -# Local hooks default to the pre-commit stage; the versioner opts into +# Local hooks default to the pre-commit stage; the commit lint opts into # commit-msg explicitly. default_stages = ["pre-commit"] @@ -33,10 +33,12 @@ entry = "tofu fmt -check -diff" language = "system" files = '\.tf$' +# Read-only: version bumping happens at release time (npm run bump), not per +# commit, so parallel worktrees stop conflicting on the version files. [[repos.hooks]] -id = "versioner" -name = "conventional commits versioner" -entry = "node scripts/version-bump.mjs" +id = "commit-lint" +name = "conventional commit header lint" +entry = "node scripts/lint-commit-msg.mjs" language = "system" stages = ["commit-msg"] diff --git a/scripts/conventional.mjs b/scripts/conventional.mjs new file mode 100644 index 0000000..2157114 --- /dev/null +++ b/scripts/conventional.mjs @@ -0,0 +1,17 @@ +// Conventional Commits parsing shared by the commit-msg lint +// (lint-commit-msg.mjs) and the release-time versioner (version-bump.mjs). + +export const TYPES = ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'] +export const HEADER = new RegExp(`^(${TYPES.join('|')})(\\([^()]+\\))?(!)?: .+`) + +// Messages git writes for us keep whatever shape git gave them. +export const GIT_SHAPES = /^(Merge |Revert |fixup! |squash! |amend! )/ + +// Semver bump for one already-matched header: breaking -> major, feat -> +// minor, fix/perf -> patch, anything else -> null. `lines` is the whole +// message, for BREAKING CHANGE footers. +export function bumpFor(header, lines) { + const [, type, , bang] = HEADER.exec(header) + const breaking = bang === '!' || lines.some((line) => /^BREAKING[- ]CHANGE: /.test(line)) + return breaking ? 'major' : type === 'feat' ? 'minor' : type === 'fix' || type === 'perf' ? 'patch' : null +} diff --git a/scripts/lint-commit-msg.mjs b/scripts/lint-commit-msg.mjs new file mode 100644 index 0000000..52657af --- /dev/null +++ b/scripts/lint-commit-msg.mjs @@ -0,0 +1,31 @@ +// Conventional Commits lint (prek commit-msg stage): reject a commit whose +// header is not a Conventional Commit. Read-only — version bumping happens at +// release time (scripts/version-bump.mjs), not per commit, so parallel +// worktrees stop contending on package.json and the manifest. +import { readFileSync } from 'node:fs' +import { GIT_SHAPES, HEADER, TYPES } from './conventional.mjs' + +const SCISSORS = '# ------------------------ >8 ------------------------' + +const msgFile = process.argv[2] +if (!msgFile) { + console.error('usage: lint-commit-msg.mjs ') + process.exit(2) +} + +// Keep the message above the scissors line (git commit -v appends the diff +// below one) and drop comment lines before looking for the header. +const raw = readFileSync(msgFile, 'utf8') +const cut = raw.indexOf(SCISSORS) +const lines = (cut === -1 ? raw : raw.slice(0, cut)) + .split('\n') + .filter((line) => !line.startsWith('#')) +const header = lines.find((line) => line.trim() !== '') ?? '' + +if (GIT_SHAPES.test(header)) process.exit(0) + +if (!HEADER.test(header)) { + console.error(`commit-lint: not a Conventional Commit header: "${header}"`) + console.error(`commit-lint: expected "()!?: " with a type in: ${TYPES.join(', ')}`) + process.exit(1) +} diff --git a/scripts/lint-commit-msg.test.mjs b/scripts/lint-commit-msg.test.mjs new file mode 100644 index 0000000..5118427 --- /dev/null +++ b/scripts/lint-commit-msg.test.mjs @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' + +const script = join(import.meta.dirname, 'lint-commit-msg.mjs') +const dir = mkdtempSync(join(tmpdir(), 'commit-lint-')) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +function run(message) { + const msgFile = join(dir, 'msg') + writeFileSync(msgFile, message) + execFileSync(process.execPath, [script, msgFile]) +} + +describe('lint-commit-msg.mjs', () => { + it('accepts Conventional Commit headers', () => { + run('feat: add a thing\n') + run('fix(popup)!: change the close behavior\n') + }) + + it('accepts messages git writes for us', () => { + run("Merge branch 'claude/thing'\n") + run('fixup! feat: add a thing\n') + }) + + it('rejects a non-conventional header', () => { + expect(() => run('added some stuff\n')).toThrow() + }) + + it('ignores comments and everything below the scissors line', () => { + run( + '# please enter the commit message\nfix: close the leak\n\n' + + '# ------------------------ >8 ------------------------\nnot a header, just the diff\n', + ) + }) +})