Files
msd-core/hooks/gsd-validate-commit.sh
Tom Boucher 4e1c449281 enh(#3811): add hooks.commit_types config surface to gsd-validate-commit (#4340)
* enh(#3811): add hooks.commit_types config surface to gsd-validate-commit

Extends the opt-in Conventional Commits hook with a hooks.commit_types
config array that adds project-specific types to the 10 built-ins
without replacing them. Configured values pass a safe-token filter
before reaching the compiled regex, so a config entry can never alter
the pattern's structure. The regex alternation, the human-readable
error text, and a new typed valid_types JSON field all derive from one
list instead of the two hand-synced copies this replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(#3811): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:35:09 -04:00

569 lines
33 KiB
Bash
Executable File

#!/usr/bin/env bash
# gsd-hook-version: {{GSD_VERSION}}
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
# Blocks git commit commands with non-conforming messages (exit 2).
# Allows conforming messages and all non-commit commands (exit 0).
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
set -euo pipefail
# Temp files created below for subprocess stderr capture (config read, command
# extraction, classifier). A single EXIT trap replaces three hand-rolled
# mktemp/rm-f pairs so an early or unexpected exit path can never leak one —
# and a future fourth check does not need its own copy (#3911 review).
# Idempotent and failure-proof by construction: unset vars expand to "" (a
# no-op rm -f target), and `|| true` guarantees the trap itself never changes
# the script's exit status.
ENABLED_ERR=""
CMD_ERR=""
CLASSIFY_ERR=""
cleanup_temp_files() {
rm -f "${ENABLED_ERR:-}" "${CMD_ERR:-}" "${CLASSIFY_ERR:-}" 2>/dev/null || true
}
trap cleanup_temp_files EXIT
# The 10 built-in Conventional Commits types — the SINGLE declaration (#3811
# review finding: this was previously hand-typed a second time inside the
# node -e script below, a generative-fix-divergence risk per CLAUDE.md's
# known-defect list). Threaded into node via an env var; reused directly by
# bash below when building COMMIT_TYPES.
BUILTIN_COMMIT_TYPES=(feat fix docs style refactor perf test build ci chore)
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED_ERR=$(mktemp)
# Single node invocation reads BOTH hooks.community (line 1: '1'/'0') and
# hooks.commit_types (remaining lines: one sanitized extra type per line) —
# see #3811. Sanitizing here, not in bash, keeps the safe-token check in one
# place and guarantees only [a-z][a-z0-9-]* strings ever reach the regex
# built below, so a configured value can never alter the compiled pattern's
# structure.
BUILTIN_COMMIT_TYPES_CSV=$(IFS=,; echo "${BUILTIN_COMMIT_TYPES[*]}")
CONFIG_OUT=$(GSD_BUILTIN_COMMIT_TYPES="$BUILTIN_COMMIT_TYPES_CSV" node -e "
try{
const c=require('./.planning/config.json');
process.stdout.write(c.hooks?.community===true?'1':'0');
process.stdout.write('\n');
const raw=c.hooks?.commit_types;
const list=Array.isArray(raw)?raw:[];
const seen=new Set((process.env.GSD_BUILTIN_COMMIT_TYPES||'').split(',').filter(Boolean));
for (const t of list){
if (typeof t!=='string') continue;
if (!/^[a-z][a-z0-9-]*\$/.test(t)) continue;
if (seen.has(t)) continue;
seen.add(t);
process.stdout.write(t+'\n');
}
}catch(e){
process.stderr.write('CONFIG_READ_FAILED: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
" 2>"$ENABLED_ERR") || CONFIG_STATUS=$?
CONFIG_STATUS=${CONFIG_STATUS:-0}
if [ "$CONFIG_STATUS" != "0" ]; then
# Could not determine the opt-in flag at all (node missing, JSON parse
# error other than absence, etc.) — distinct from ".planning/config.json
# exists and legitimately disables the hook". Say so and pass, per #3838.
echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
exit 0
fi
ENABLED=$(printf '%s\n' "$CONFIG_OUT" | head -1)
if [ "$ENABLED" != "1" ]; then exit 0; fi
# Remaining lines (if any) are the sanitized, deduped configured commit
# types beyond the 10 built-ins (#3811). Read into a bash-3.2-safe array —
# `mapfile`/`readarray` are bash 4+ only and this hook is tested against
# bash 3.2.57 (macOS default).
EXTRA_COMMIT_TYPES=()
while IFS= read -r _extra_type; do
[ -n "$_extra_type" ] && EXTRA_COMMIT_TYPES+=("$_extra_type")
done < <(printf '%s\n' "$CONFIG_OUT" | tail -n +2)
else
exit 0
fi
INPUT=$(cat)
# Extract command from JSON using Node (handles escaping correctly, no jq needed)
CMD_ERR=$(mktemp)
CMD=$(echo "$INPUT" | node -e "
let d='';
process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try{
process.stdout.write(JSON.parse(d).tool_input?.command||'');
}catch(e){
process.stderr.write('COMMAND_EXTRACTION_FAILED: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
});
" 2>"$CMD_ERR") || CMD_STATUS=$?
CMD_STATUS=${CMD_STATUS:-0}
if [ "$CMD_STATUS" != "0" ]; then
# Could not extract tool_input.command at all (node missing, malformed
# JSON, etc.) — distinct from "there is genuinely no command field". Say
# so and pass, per #3838.
echo "gsd-validate-commit.sh: could not extract tool_input.command from the hook payload — validator disabled for this call. $(cat "$CMD_ERR")" >&2
exit 0
fi
# Only check git commit commands.
# Delegates to hooks/lib/git-cmd.js isGitSubcommand() — the canonical token-walk
# classifier that handles env-prefix, -C path, and full-path git invocations.
# A naive `^git\s+commit` regex misses all three; this guard fixes that (#3129).
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
CLASSIFY_ERR=$(mktemp)
GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" node -e "
try {
const {isGitSubcommand}=require(process.env.GIT_CMD_LIB);
process.exit(isGitSubcommand(process.argv[1],'commit')?0:1);
} catch(e) {
process.stderr.write('CLASSIFIER_THREW: '+(e&&e.message?e.message:String(e)));
process.exit(3);
}
" "$CMD" 2>"$CLASSIFY_ERR" || CLASSIFY_STATUS=$?
CLASSIFY_STATUS=${CLASSIFY_STATUS:-0}
if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
# 0 = is a git commit (validate below); 1 = genuinely not a git commit
# (real negative, pass silently) — the ONLY intentional non-zero exit the
# script above ever produces on success. Any other status — 127 node
# missing, or 3 from the try/catch above when the git-cmd.js require chain
# throws (e.g. its built dependency, gsd-core/bin/lib/token-scanner.cjs, is
# a gitignored build artifact and absent on a fresh checkout — run
# `npm run build:lib`) — means the classifier could not run at all. Say so
# on stderr and pass (#3838): PreToolUse stderr does not disturb the JSON
# protocol.
echo "gsd-validate-commit.sh: could not classify the command via hooks/lib/git-cmd.js (exit $CLASSIFY_STATUS) — validator disabled for this call. If this persists, run \`npm run build:lib\`. $(cat "$CLASSIFY_ERR")" >&2
exit 0
fi
if [ "$CLASSIFY_STATUS" = "0" ]; then
# Extract message from -m flag.
#
# MSG_QUOTE records WHICH arm matched. bash treats the two arms differently
# and the subject step below depends on that difference — see the resolver
# gate (review of #3816, round 4).
MSG=""
MSG_QUOTE=""
MSG_MATCH=""
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
MSG="${BASH_REMATCH[1]}"
MSG_QUOTE=dq
MSG_MATCH="${BASH_REMATCH[0]}"
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
MSG="${BASH_REMATCH[1]}"
MSG_QUOTE=sq
MSG_MATCH="${BASH_REMATCH[0]}"
fi
if [ -n "$MSG" ]; then
# Subject = first line of the message, EXCEPT for the command-substituted
# heredoc form, where the first line is the opener rather than the message:
#
# git commit -m "$(cat <<'EOF'
# feat(auth): add login flow
# EOF
# )"
#
# The capture above spans it whole, because bash `[^"]` matches newlines, so
# `head -1` yielded the literal `$(cat <<'EOF'` and EVERY heredoc-form commit
# was blocked regardless of its message (#3802).
#
# Selection of WHICH argument is the message is unchanged above — only the
# subject-from-message step is delegated. Falls back to the previous `head -1`
# if node or the library is unavailable, so a broken extractor degrades to the
# old behavior instead of becoming a new silent-allow path.
#
# SINGLE-QUOTE GATE (review of #3816, round 4 — BLOCKER). The resolver may
# only run on the DOUBLE-quoted arm. Inside `-m '...'` bash performs NO
# command substitution, so `$(cat <<'EOF'` is literal text and git's real
# subject is that opener line — resolving the body there validates a
# message git never receives. Measured against the real hook, all four
# spellings (`<<'E'`, `<<"E"`, `<<\E`, `<<E`) went base=2 -> head=0: a
# net-new bypass reachable by the ordinary authoring slip of typing `'`
# for `"`. The sq arm therefore keeps the pre-fix `head -1`, which is exact
# base parity.
#
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
# argument, so the capture above holds only a PREFIX of the real message.
# Resolving a heredoc from a prefix hands the length gate a fraction of the
# real subject: a net-new bypass relative to base, which measured the
# opener line and blocked. When the quote is not followed by whitespace or
# the end of the command, skip the resolver and keep the pre-fix subject
# (first captured line): the heredoc form then fails the format gate
# exactly as it did on base, and the plain single-line form keeps base
# behavior unchanged. The guard is tested against the arm that MATCHED,
# not against both: testing both let a double-quoted heredoc whose BODY
# mentions a glued single-quoted token (`-m "... -m 'foo'bar ..."`) trip
# the sq arm and lose the fix for a message that never had a prefix
# problem (review of #3816, round 4, Minor 1).
# RESOLVER PRECONDITIONS. The resolver may run only where the captured text
# is provably the subject git receives. Each guard names an input where it
# is not; every refusal falls back to `head -1`, the pre-fix subject, which
# fails the format gate exactly as this whole form did before the fix.
RESOLVE=0
if [ "$MSG_QUOTE" = dq ]; then
RESOLVE=1
# Text before the message we matched. The heredoc BODY always sits after
# the match, so this window cannot be contaminated by message content —
# which is what lets the two guards below scan for tokens that would also
# be legal inside a commit message.
MSG_PREFIX="${CMD%%"$MSG_MATCH"*}"
# Text after it. Together, PREFIX and SUFFIX are the whole command MINUS
# the message — the window a guard must use when the token it scans for
# is also legal English inside a commit message, but may legally appear
# on EITHER side of the message on the command line.
MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"
# LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
# `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
# spread an invocation over lines, and every guard below reads a newline in
# a window as a command separator, so the whole form was refused. That was
# disclosed as a fail-closed limit in round 8 because "is this newline a
# continuation" looked like the segmentation question this file has
# reverted twice. It is not: bash's rule is local and character-level. A
# newline preceded by an ODD run of backslashes is a continuation and bash
# removes both; an EVEN run (`\\` then newline) is a literal backslash
# followed by a real newline, which IS a separator. So the windows are
# joined the way bash joins them, in three bash-3.2-safe steps: every `\\`
# pair is parked on \x01, a byte no real command line carries, any
# backslash-newline that remains is a lone (odd) one and is removed, then
# the pairs are restored. Applied to BOTH windows, BEFORE the dequote
# copies are derived, so every scan sees the joined text.
#
# KNOWN OVER-BLOCK, fail-closed: a literal \x01 that IS present in the
# command is restored as `\\`, so an option-shaped token carrying one
# (`-\x01m`) reads as `-\\m`, dequotes to `-m`, and refuses where it did
# not before (independent review, round 9). Refusing is the recoverable
# direction; a control byte in an option name is not a spelling anyone
# types, and it is not a hole in the accept direction.
#
# Direction check: a continuation glued to the closing quote
# (`"$(…)"\` newline `suffix`) joins to `"$(…)"suffix`, which the glue
# guard refuses exactly as bash would have glued it; `\\` + newline keeps
# its newline and is still refused by the separator guard. Measured on
# bash 3.2.57 and 5.3.15 in tests/hooks-opt-in.test.cjs.
CONT_PARK=$'\x01'
MSG_PREFIX="${MSG_PREFIX//\\\\/$CONT_PARK}"
MSG_PREFIX="${MSG_PREFIX//\\$'\n'/}"
MSG_PREFIX="${MSG_PREFIX//$CONT_PARK/\\\\}"
MSG_SUFFIX="${MSG_SUFFIX//\\\\/$CONT_PARK}"
MSG_SUFFIX="${MSG_SUFFIX//\\$'\n'/}"
MSG_SUFFIX="${MSG_SUFFIX//$CONT_PARK/\\\\}"
# QUOTE-SPLICED SPELLINGS (independent review of #3816, round 6). Bash
# removes quotes before git ever sees an argument, so the same option has
# unboundedly many spellings on the command line: `--clean""up=verbatim`
# IS `--cleanup=verbatim` to git, and `-""m` IS `-m`. Both matched no
# literal and were measured ACCEPTING a 75-byte subject the length gate
# had recorded as 72. The guards below therefore scan a copy of their
# window with quote characters removed, which is what bash does to it.
# Only the two OPTION-NAME scans use it; the adjacency test deliberately
# does not, because it asks about a literal character position, and the
# message span itself is excluded from both windows either way.
MSG_PREFIX_DEQ="${MSG_PREFIX//[\"\']/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX//[\"\']/}"
# BACKSLASH-SPLICED SPELLINGS (independent review of #3816, round 7).
# Quote removal alone was not "the command as bash hands it to git": bash
# also removes syntactic backslashes, so `-\m WIP` IS `-m WIP` and
# `--clean\up=verbatim` IS `--cleanup=verbatim` to git, and both matched
# no literal. Measured: `-\m WIP -m <conforming heredoc>` accepted the
# heredoc while git recorded `WIP`, and a trailing `--clean\up=verbatim`
# accepted a 75-byte subject the length gate measured as 72. Stripped in a
# second pass so the class is unambiguous.
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\\/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\\/}"
# DOLLAR-QUOTED SPELLINGS (independent review of #3816, round 8). The two
# passes above still were not "the command as bash hands it to git": bash
# has TWO more quoting forms whose introducer is a `$`, and removing the
# quote characters alone leaves that `$` stranded in the middle of the
# option name. `-$"m"` became `-$m` here while bash passes a real `-m` to
# git, and `--mes$'sage'=WIP` became `--mes$sage=WIP`; neither matched any
# literal, so the first-message guard below never fired. Measured on bash
# 3.2.57 and 5.3.15 against a real repository: the hook allowed
# `-$"m" WIP -m <conforming heredoc>` (exit 0) while `git cat-file -p`
# recorded the subject `WIP` — the same command spelled `-m WIP` is
# refused (exit 2). Stripping `$` closes both dollar-quote forms.
#
# RESIDUAL, and not fixable from a string: an option name assembled by an
# EXPANSION — `-${x}m`, `-$(printf m)` — is not knowable without running
# the command, the same limit this file already documents for expanded
# heredoc bodies. Stripping `$` makes those spellings collapse toward the
# literal too, which over-matches, and over-matching only refuses more.
MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\$/}"
MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\$/}"
# ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
# `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
# argument, so the capture holds only a PREFIX of the real message, and
# the length gate would measure a fraction of the real subject.
# SCOPE (review of #3816, round 6 — MAJOR). Glue is a property of the ONE
# character following the MATCHED span, so that character is the whole
# window. Scanning $CMD for the shape anywhere refused any conforming
# commit whose command merely CONTAINED a glued `-m` elsewhere —
# `git commit -m "<heredoc>" && echo -m "test"z` stayed blocked with
# CONVENTIONAL_COMMITS_VIOLATION. Base blocks it too, because base blocks
# EVERY heredoc form (that is #3802): this was the fix not reaching the
# shape, measured base=2 -> pre=2 -> post=0, not a regression.
# The separators and redirections are excluded because bash does NOT
# concatenate across them: in `-m "msg"&& echo hi` the argument ends at
# the quote, so there is no truncated capture to defend against.
# The class is held in a VARIABLE, not written inline. Inline, every
# member needs a backslash to get past the `[[ ]]` parser (`;`, `&` and
# `|` are metacharacters there) — and on bash 3.2, the system /bin/bash on
# macOS, those backslashes are passed THROUGH to the regex engine instead
# of being consumed by the shell, silently adding a literal `\` to the
# class. Unquoted expansion of a variable on the right of `=~` is the one
# spelling that is a plain regex on 3.2 and 5.x alike (review of #3816,
# round 8). Writing `[^[:space:];&|()<>]` inline is NOT the fix: it is a
# bash syntax error on both versions.
GLUE_CLASS='^[^[:space:];&|()<>]'
if [[ "$MSG_SUFFIX" =~ $GLUE_CLASS ]]; then RESOLVE=0; fi
# FIRST-MESSAGE GUARD (Codex review of #3816, round 4 — BLOCKER). The
# capture is a SEARCH over the whole command and the double-quoted arm is
# tried first, so it can select a `-m` that is not git's subject at all:
#
# git commit -m 'WIP first' -m "$(cat <<'EOF' -> git concatenates; the
# git commit -m WIP -m "$(cat <<'EOF' subject is `WIP first`
# git commit -m WIP -- -m "$(cat <<'EOF' -> after --, not a message
# git commit -m WIP && echo -m "$(cat <<'EOF' -> belongs to `echo`
#
# All four measured base=2 -> head=0, with git recording the FIRST message
# as the subject (verified against real commits, not the man page). The
# mis-selection is pre-existing; resolving it is what turned it into an
# enforcement bypass. Resolve only when nothing before the match could
# have been an earlier message, an end-of-options marker, or another
# command.
# BUNDLED SHORT OPTIONS (independent review of #3816, round 6). git splits
# `-am 'WIP first'` into `-a -m`, so the real subject is `WIP first` and
# the heredoc is git's SECOND message — measured accepting the heredoc's
# subject while git recorded `WIP first`. A standalone `-m` is therefore
# not the only spelling that claims the message; any short-option cluster
# ending in `m` does.
# ATTACHED VALUES AND --message ABBREVIATIONS (independent review of
# #3816, round 7). The scan required a space or `=` after the option name,
# so two spellings git accepts matched nothing: an ATTACHED short-option
# value (`-mWIP`, which git reads as `-m WIP`) and a long-option
# abbreviation (`--mes=WIP`), the same abbreviation behaviour this file
# already models for `--cleanup`. Both were measured accepting a later
# conforming heredoc while git recorded `WIP` as the subject — confirmed
# against the raw commit object, not `git log --pretty=%s`. The short arm
# therefore drops its trailing requirement entirely: a `-` followed by
# letters ending in `m` claims the message however it is spelled. Wider
# than git's own abbreviation set on purpose — over-matching only refuses
# more, which is the recoverable direction.
# Variable-held for the same bash-3.2 reason as GLUE_CLASS above.
# AN OPTION NAME BUILT BY A COMMAND SUBSTITUTION IS UNRESOLVABLE
# (independent review of #3816, round 8). Stripping `$` above collapses the
# two dollar-QUOTE forms onto their literals, but `--clean$(printf up)=`
# is a different thing: bash RUNS a program to finish the option name, so
# the argv git receives is not derivable from this string at all. Measured
# accepting a 75-byte subject the length gate had recorded as 72.
#
# SCOPED TO THE NAME, NOT THE VALUE. The class is a `-`-leading token whose
# characters up to the substitution contain no `=` — an option NAME being
# assembled. `--author="$(git config user.name)"` and `--author "$(…)"`
# both put the substitution in the VALUE, which this file never models and
# which stays allowed; only `-…$(` before any `=` refuses. Scanned on the
# RAW windows on purpose: the dequoted copies have had their `$` removed,
# so the shape is no longer visible there.
#
# This is a SHAPE, not a segmentation: it never tries to decide where
# git's own command ends. Two attempts at that were reverted for opening
# accept-direction holes, and the reasoning above still stands.
# WIDENED, and the strategy changed with it (independent review, round 9).
# The `$(`-only spelling above was the fourth patch in a row that tried to
# EMULATE what bash does to an argument before git sees it -- round 6
# removed quotes, round 7 backslashes, round 8 the `$` of a dollar-quote,
# and each time review found another transform that had been missed. Round
# 9 found four more, all measured accepting `WIP` as the real subject on
# bash 3.2.57 and 5.3.15 while the plain spelling of the same command is
# refused:
#
# -$'\155' WIP ANSI-C octal escape decodes to `m`
# -$'\x6d' WIP ANSI-C hex escape decodes to `m`
# -`printf m` WIP command substitution, backtick spelling
# x= … -${x}m WIP parameter expansion
# -? WIP pathname expansion, with a file named `-m`
#
# The last two settle the strategy: an option name finished by a PARAMETER
# expansion depends on a variable's runtime value, and one finished by a
# PATHNAME expansion depends on the contents of the working directory.
# Neither is derivable from the command string at any level of effort, so
# emulation cannot be completed -- not "has not been completed yet".
#
# So the rule is no longer "normalise it and match the literal". It is: an
# option NAME containing a shell expansion or quoting construct is
# UNRESOLVABLE, and unresolvable refuses. One rule covers every spelling
# above, and every spelling nobody has thought of yet, in the fail-closed
# direction. The dequoting passes above are kept: they still normalise the
# deterministic removals so the guards RECOGNISE `--clean""up=` and `-\m`
# rather than merely refusing them, which keeps the existing rows honest.
#
# SCOPED TO THE NAME, NOT THE VALUE, exactly as before: the class is a
# `-`-leading token whose characters up to the substitution contain no `=`.
# `--author="$(git config user.name)"` and `--author "$(…)"` put the
# construct in the VALUE and still resolve, pinned in both directions.
# Scanned on the RAW windows, because the dequoted copies have had `$` and
# the quote characters removed and the shape is no longer visible there.
#
# The class is bracket-only and holds no backslash, per round 8: a POSIX
# bracket expression has no escape mechanism, and a backslash written
# inside one becomes a literal member on bash 3.2.
SUBST_NAME_CLASS='(^|[[:space:]])-[^[:space:]=]*[$`?*[]'
if [[ "$MSG_PREFIX" =~ $SUBST_NAME_CLASS ]] \
|| [[ "$MSG_SUFFIX" =~ $SUBST_NAME_CLASS ]]; then RESOLVE=0; fi
SEP_CLASS='[;&|]'
if [[ "$MSG_PREFIX_DEQ" =~ (^|[[:space:]])(-[a-zA-Z]*m|--m[a-z]*([=[:space:]]|$)) ]] \
|| [[ "$MSG_PREFIX" =~ (^|[[:space:]])--([[:space:]]|$) ]] \
|| [[ "$MSG_PREFIX" =~ $SEP_CLASS ]] \
|| [[ "$MSG_PREFIX" == *$'\n'* ]]; then RESOLVE=0; fi
# NEWLINE IS A COMMAND SEPARATOR TOO (independent review of #3816, round
# 7) — the test above. The separator scan covered `;`, `&` and `|` but not
# a literal newline, so a LATER command's heredoc-shaped `-m` was taken
# for this commit's message:
#
# git commit --amend --no-edit
# echo -m "$(cat <<'EOF'
# fix: conforming text unrelated to the commit
# EOF
# )"
#
# The classifier recognises the leading commit, the capture reaches across
# the newline into `echo`'s argument, and a conforming string with no
# relationship to the commit was validated and allowed. Tested as a glob
# rather than folded into the bracket class, because a literal newline
# inside a bash regex bracket expression is not portably expressible.
# CLEANUP-MODE GUARD (Codex review of #3816, round 4 — BLOCKER). The
# resolver skips leading blank lines and strips trailing whitespace
# because git's DEFAULT cleanup=whitespace does. Under
# `--cleanup=verbatim` git does neither, so a 72-char subject plus three
# trailing spaces is committed as a 75-byte subject while the hook
# measured 72 — COMMIT_SUBJECT_TOO_LONG dodged (measured base=2 -> head=0;
# confirmed by reading the raw commit object, since `git log --pretty=%s`
# strips trailing whitespace in its own output and hides it).
# Any named mode other than `whitespace` refuses. A mode set persistently
# in git config is invisible here and stays a documented residual limit.
# SCOPE (review of #3816, round 5 — BLOCKER). This scan must exclude the
# message. `--cleanup=` and `commit.cleanup=` are ordinary English inside
# a commit message — this repository's own hooks and docs discuss them
# constantly — and the heredoc BODY sits verbatim inside $CMD, so
# scanning $CMD refused to resolve any conforming message that merely
# MENTIONED the token, blocking it with CONVENTIONAL_COMMITS_VIOLATION.
# Scanning $MSG_PREFIX alone (the fix as first prescribed) would reopen
# the bypass this guard exists for: git accepts the flag on either side
# of -m, and `git commit -m "<heredoc>" --cleanup=verbatim` is caught
# today only because the scan is command-wide. PREFIX + SUFFIX keeps both
# positions covered while excluding the one span that is message text.
# The two are joined with a space so a token cannot be forged across the
# seam out of a prefix tail and a suffix head.
# KNOWN LIMIT, deliberately fail-closed (#3816, round 6). This window is
# the whole command minus the message, so a `--cleanup=` that belongs to a
# DIFFERENT command — `git commit -m "<heredoc>" && echo --cleanup=verbatim`
# — also refuses, and a conforming commit git would accept stays blocked.
# Narrowing it to git's own segment was tried and reverted: deciding where
# git's command ends needs a shell parse, and a substring scan is not one.
# Trimming at the first `;&|` cut the window short whenever a separator sat
# inside an ordinary argument — `--author "a&b"`, and equally `--author
# a\&b` — which hid a REAL trailing `--cleanup=verbatim` and ACCEPTED a
# 75-byte subject the length gate had measured as 72. Two successive
# narrowings each reopened that hole on a shape the previous one missed, so
# the scan stays wide: refusing a commit git would take is recoverable,
# accepting an over-long subject is not.
# ABBREVIATIONS (independent review of #3816, round 6). git accepts any
# unambiguous prefix of a long option, so `--cle=verbatim` sets the mode
# while matching no literal `--cleanup` — measured accepting a 75-byte
# subject recorded as 72. The class is deliberately wider than git's own
# abbreviation set: over-matching only refuses more, which is the safe
# direction, and no other `--cl` option exists for git commit.
# LAST DIRECTIVE WINS, AND ONE MATCH CANNOT SEE IT (independent review of
# #3816, round 7). A bash regex yields ONE BASH_REMATCH, so only the
# FIRST cleanup directive was inspected — and git applies the LAST one.
# `--cleanup=whitespace -m <heredoc> --cleanup=verbatim` therefore read as
# mode=whitespace, resolution stayed enabled, and a 72-character subject
# plus trailing spaces was accepted while git recorded 75 bytes with the
# whitespace preserved (confirmed against the raw commit object). Deciding
# WHICH directive is last needs an argv order this substring scan does not
# have, so multiplicity itself refuses: more than one directive is
# unresolvable, not "probably fine". Single-directive behaviour is
# unchanged.
CLEANUP_WINDOW="$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ"
# `|| true` is load-bearing: this script runs under `set -euo pipefail`,
# and grep exits 1 when it matches NOTHING — which is the common case, a
# command with no cleanup directive at all. Without it the pipeline's
# non-zero status killed the hook outright (exit 1, no verdict) for every
# ordinary commit. Caught by running the real hook rather than the scan.
CLEANUP_HITS=$( { printf '%s' "$CLEANUP_WINDOW" | grep -oE '(--cl[a-z]*|commit\.cleanup)[=[:space:]]+[^[:space:]]+' || true; } | wc -l | tr -d ' ')
if [ "${CLEANUP_HITS:-0}" -gt 1 ]; then
RESOLVE=0
elif [[ "$CLEANUP_WINDOW" =~ (--cl[a-z]*|commit\.cleanup)[=[:space:]]+([^[:space:]]+) ]]; then
if [ "${BASH_REMATCH[2]}" != "whitespace" ]; then RESOLVE=0; fi
fi
# GIT-GENERATED SUBJECTS (independent review of #3816, round 7). With
# `--squash=<commit>` or `--fixup=<commit>` git composes the subject
# itself — measured recording `squash! base: something` while a conforming
# heredoc supplied via -m sailed through. The supplied message is not the
# subject in these modes at all, so there is nothing here worth measuring
# and resolution is refused outright. Abbreviations included for the same
# reason as --cleanup's. Deliberately NOT extended to the other
# message-SOURCE options (-C/--reuse-message, -c/--reedit-message,
# -F/--file, -t/--template): `-c` is also a git GLOBAL option that legally
# precedes the subcommand, so a scan for it would refuse ordinary
# `git -c k=v commit` invocations. Those remain a disclosed gap rather
# than a guessed guard.
if [[ "$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ" =~ (^|[[:space:]])--(squash|fixup|sq[a-z]*|fix[a-z]*)[=[:space:]] ]]; then RESOLVE=0; fi
fi
if [ "$RESOLVE" = 1 ]; then
SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
process.stdout.write(resolveCommitSubject(process.env.MSG));
" 2>/dev/null) || SUBJECT=$(echo "$MSG" | head -1)
else
SUBJECT=$(echo "$MSG" | head -1)
fi
# Single source of truth for the accepted commit-type list (#3811): the
# 10 built-ins plus whatever passed the safe-token filter above. Both the
# regex alternation and the human-readable error text below are derived
# from this ONE array — no hand-synced second copy.
#
# The `"${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}"` form (not
# plain `"${EXTRA_COMMIT_TYPES[@]}"`) is required: on bash 3.2.57 (this
# repo's macOS test target), expanding `[@]` on an array that is declared
# but has zero elements throws "unbound variable" under `set -u` (which
# this script has via `set -euo pipefail`). Verified directly against
# /bin/bash 3.2.57 on macOS. The `${arr[@]+word}` form is the
# nounset-safe idiom for "expand if set, empty otherwise" on empty arrays.
COMMIT_TYPES=("${BUILTIN_COMMIT_TYPES[@]}" "${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}")
COMMIT_TYPE_ALT=$(IFS='|'; echo "${COMMIT_TYPES[*]}")
COMMIT_TYPE_LIST=$(printf '%s, ' "${COMMIT_TYPES[@]}")
COMMIT_TYPE_LIST="${COMMIT_TYPE_LIST%, }"
# Typed `valid_types` array (#3811 review finding): CONTRIBUTING.md bans
# substring/prose matching on `reason` in tests — a test needing to
# verify the accepted-type set must have a typed field, not grep prose.
# Safe to build with a bare printf (no JSON-escaping needed): every
# element of COMMIT_TYPES has already passed the `^[a-z][a-z0-9-]*$`
# safe-token filter (or is a literal built-in), so none can contain `"`
# or `\`.
COMMIT_TYPES_JSON=$(printf '"%s",' "${COMMIT_TYPES[@]}")
COMMIT_TYPES_JSON="[${COMMIT_TYPES_JSON%,}]"
# Validate Conventional Commits format
if ! [[ "$SUBJECT" =~ ^($COMMIT_TYPE_ALT)(\(.+\))?:[[:space:]].+ ]]; then
# Emit typed `code` and `valid_types` fields alongside `reason` (#2974,
# #3811). Tests assert on the stable code string and the typed array;
# the reason is the human-readable copy, never grepped by tests.
echo "{\"decision\": \"block\", \"code\": \"CONVENTIONAL_COMMITS_VIOLATION\", \"valid_types\": $COMMIT_TYPES_JSON, \"reason\": \"Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: $COMMIT_TYPE_LIST. Subject must be <=72 chars, lowercase, imperative mood, no trailing period.\"}"
exit 2
fi
if [ ${#SUBJECT} -gt 72 ]; then
echo '{"decision": "block", "code": "COMMIT_SUBJECT_TOO_LONG", "reason": "Commit subject must be 72 characters or less."}'
exit 2
fi
fi
fi
exit 0