mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 09:25:24 -04:00
chore(husky): add git hook rejecting bare @mentions in commit messages (#12387)
* chore: add git hook rejecting bare `@mentions` in commit messages * fix(husky): align bare-mention boundary with GitHub linkification rules * fix(husky): keep mention boundaries intact when stripping code spans * fix(husky): ignore the verbose-commit diff below the scissors line * refactor(husky): split bare-mention hook into small single-purpose functions * fix(husky): exempt tilde-fenced code blocks from mention scanning * fix(husky): trim trailing punctuation from the reported scoped handle * refactor(husky): detect bare mentions by scanning, not regex Replace the single packed detection regex with a plain character scan and small predicate functions: for each `@`, exempt it when it sits inside backticks (odd backtick count before it), when it is not followed by an ASCII letter/digit, or when it is preceded by one (email-like). This is easier to read and reason about than the regex. Side effect: the `~~~` tilde-fence special case is gone, so a mention inside a `~~~` block is now flagged. Backtick code spans and fences are still exempt. * docs(husky): note readHandle precondition that the char after @ is alphanumeric * refactor(husky): spell out abbreviated identifiers in the mention hook --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
node .husky/reject-bare-issue-refs.mjs "$1"
|
||||
node .husky/reject-bare-mentions.mjs "$1"
|
||||
pnpm commitlint --edit --config=commitlint.config.cjs
|
||||
|
||||
134
.husky/reject-bare-mentions.mjs
Normal file
134
.husky/reject-bare-mentions.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
// Rejects commit messages that contain a bare `@name` mention — one that is not
|
||||
// wrapped in backticks. GitHub turns such a token into a real notification.
|
||||
//
|
||||
// Rationale lives in the error message below and in AGENTS.md.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const messagePath = process.argv[2]
|
||||
if (!messagePath) {
|
||||
console.error('reject-bare-mentions: missing commit message file path argument')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const offenders = findBareMentions(scannableText(readFileSync(messagePath, 'utf8')))
|
||||
|
||||
if (offenders.size === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
reportAndExit(offenders)
|
||||
|
||||
// Reduce the raw commit-message file to the text git will actually keep: the
|
||||
// diff that `git commit -v` appends below the scissors line is dropped, and
|
||||
// `#` comment lines are dropped — git strips both, so a mention in either is
|
||||
// never committed and must not be flagged.
|
||||
function scannableText (raw) {
|
||||
return stripCommentLines(stripScissorsSection(raw))
|
||||
}
|
||||
|
||||
function stripScissorsSection (raw) {
|
||||
const lines = raw.split('\n')
|
||||
const cut = lines.findIndex(isScissorsLine)
|
||||
return cut === -1 ? raw : lines.slice(0, cut).join('\n')
|
||||
}
|
||||
|
||||
// git's `commit -v` cut line, e.g. "# ------------------------ >8 ------------------------".
|
||||
function isScissorsLine (line) {
|
||||
return line.startsWith('#') && line.includes('>8') && line.includes('--')
|
||||
}
|
||||
|
||||
function stripCommentLines (text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('#'))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Find every distinct `@handle` that GitHub would linkify as a mention. We only
|
||||
// care about GitHub's rendering, not the exact username rules: an `@` is a
|
||||
// mention when it is not inside code, is followed by an ASCII letter or digit,
|
||||
// and is not preceded by one (which would make it part of an email address).
|
||||
function findBareMentions (text) {
|
||||
const offenders = new Set()
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '@') continue
|
||||
if (isInsideBackticks(text, i)) continue
|
||||
if (!isAsciiAlphaNumeric(text[i + 1])) continue
|
||||
if (isAsciiAlphaNumeric(text[i - 1])) continue
|
||||
offenders.add(readHandle(text, i))
|
||||
}
|
||||
return offenders
|
||||
}
|
||||
|
||||
// An `@` sits inside a code span when an odd number of backticks precede it (one
|
||||
// is still open). This covers inline `` `code` `` and triple-backtick fences
|
||||
// alike, without parsing Markdown.
|
||||
function isInsideBackticks (text, index) {
|
||||
let backticks = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (text[i] === '`') backticks++
|
||||
}
|
||||
return backticks % 2 === 1
|
||||
}
|
||||
|
||||
// Read the handle starting at the `@`, for display in the error message. The
|
||||
// caller only invokes this once the char after `@` is an ASCII alphanumeric, so
|
||||
// that first char is always kept; trailing punctuation is then trimmed so the
|
||||
// reported token is the part GitHub would actually link (e.g. the sentence-ending
|
||||
// dot in "@pnpm/core." is dropped).
|
||||
function readHandle (text, atIndex) {
|
||||
let end = atIndex + 1
|
||||
while (isHandleCharacter(text[end])) end++
|
||||
while (end > atIndex + 1 && !isAsciiAlphaNumeric(text[end - 1])) end--
|
||||
return text.slice(atIndex, end)
|
||||
}
|
||||
|
||||
function isHandleCharacter (character) {
|
||||
return isAsciiAlphaNumeric(character) ||
|
||||
character === '-' ||
|
||||
character === '_' ||
|
||||
character === '.' ||
|
||||
character === '/'
|
||||
}
|
||||
|
||||
function isAsciiAlphaNumeric (character) {
|
||||
return character !== undefined && (
|
||||
(character >= 'a' && character <= 'z') ||
|
||||
(character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9')
|
||||
)
|
||||
}
|
||||
|
||||
function reportAndExit (offenders) {
|
||||
const list = [...offenders].join(', ')
|
||||
console.error(`
|
||||
✖ Commit message rejected: bare @mention(s) found: ${list}
|
||||
|
||||
A bare "@name" is ambiguous and frequently wrong. Wrap it in backticks
|
||||
instead, or remove it.
|
||||
|
||||
WHY THIS IS BLOCKED
|
||||
GitHub turns any "@name" into a mention of that user/org/team. That is
|
||||
wrong in both of the ways "@name" is normally meant:
|
||||
|
||||
1. If it is code (a scoped package like @pnpm/core, a handle, a path),
|
||||
GitHub should NOT treat it as a mention.
|
||||
|
||||
2. If it really is a person, every push, force-push, and rebase that
|
||||
carries the commit re-notifies them — which is noise nobody asked for.
|
||||
|
||||
HOW TO FIX
|
||||
Wrap the reference in backticks so GitHub renders it as code and sends no
|
||||
notification:
|
||||
|
||||
@pnpm/core -> \`@pnpm/core\`
|
||||
@foo -> \`@foo\`
|
||||
|
||||
If you do not need the reference at all, just remove it.
|
||||
|
||||
DO NOT bypass this check with --no-verify, by editing/deleting this hook, or
|
||||
with any suppression file. Fix the mention in the commit message instead.
|
||||
`)
|
||||
process.exit(1)
|
||||
}
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -171,6 +171,17 @@ For references to issues/PRs in **this** repo, also use the qualified form `pnpm
|
||||
|
||||
**Address the root cause when the hook fires.** Rewrite the reference into the correct unambiguous form. Never bypass the check with `git commit --no-verify`, by editing or deleting the hook, or with any suppression file.
|
||||
|
||||
### Never use a bare `@mention`
|
||||
|
||||
**Do not write a bare `@name` (an `@` followed by a username-like token) anywhere in a commit message.** A `commit-msg` hook (`.husky/reject-bare-mentions.mjs`) rejects them.
|
||||
|
||||
GitHub turns any `@name` into a mention of that user/org/team, which is wrong either way it is meant:
|
||||
|
||||
- If it is code (a scoped package like `@pnpm/core`, a handle, a path), GitHub should not treat it as a mention.
|
||||
- If it really is a person, every push, force-push, and rebase that carries the commit re-notifies them — noise nobody asked for.
|
||||
|
||||
**Fix:** wrap the reference in backticks so GitHub renders it as code and sends no notification — e.g. `` `@pnpm/core` `` or `` `@foo` `` — or remove it if it is not needed. Never bypass the check with `git commit --no-verify`, by editing or deleting the hook, or with any suppression file.
|
||||
|
||||
## Changesets (TypeScript only)
|
||||
|
||||
If your changes affect published packages, you MUST create a changeset file in the `.changeset` directory. The changeset file should describe the change and specify the packages that are affected with the pending version bump types: patch, minor, or major.
|
||||
|
||||
Reference in New Issue
Block a user