Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
658 lines
39 KiB
Bash
Executable File
658 lines
39 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# msd-hook-version: {{MSD_VERSION}}
|
|
# msd-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 MSD 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.
|
|
# Subprocess exit statuses, pre-initialised so they can never be inherited from
|
|
# the ambient environment. Each is captured as `... || VAR=$?`, which assigns
|
|
# ONLY on the failure branch; on success the variable keeps whatever it already
|
|
# held, and `${VAR:-0}` defaults only when unset or empty. So an EXPORTED
|
|
# CONFIG_STATUS / CMD_STATUS / CLASSIFY_STATUS — from a CI wrapper, a .envrc, or
|
|
# another hook — survived into the success path and was read as "the subprocess
|
|
# failed". Measured: `CLASSIFY_STATUS=3 git commit -m "nope: bad"` printed
|
|
# "validator disabled for this call" and exited 0, silently accepting a
|
|
# non-conforming commit. Same for CONFIG_STATUS and CMD_STATUS. Found by the
|
|
# security review of #4429; the gate is fail-open by design on a genuine
|
|
# subprocess failure (#3838), which is exactly what made this bypass quiet.
|
|
CONFIG_STATUS=0
|
|
CMD_STATUS=0
|
|
CLASSIFY_STATUS=0
|
|
|
|
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=$(MSD_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.MSD_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=$?
|
|
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
|
|
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
|
|
# already held — and an EXPORTED variable of this name is inherited from the
|
|
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
|
|
# `CONFIG_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
|
|
# silently accepting a non-conforming commit. Found by review of #4429.
|
|
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 "msd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
|
|
exit 0
|
|
fi
|
|
# Pure parameter expansion, not `printf ... | head -1`: same SIGPIPE race
|
|
# class as the SUBJECT extraction below (`echo "$MSG" | head -1`) — CONFIG_OUT
|
|
# is multi-line whenever extra commit types are configured, and `head -1`
|
|
# closing early can SIGPIPE `printf` under `set -euo pipefail`.
|
|
ENABLED="${CONFIG_OUT%%$'\n'*}"
|
|
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=$?
|
|
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
|
|
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
|
|
# already held — and an EXPORTED variable of this name is inherited from the
|
|
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
|
|
# `CMD_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
|
|
# silently accepting a non-conforming commit. Found by review of #4429.
|
|
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 "msd-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=$?
|
|
# Pre-initialised, NOT left to `${...:-0}` alone: the capture below only
|
|
# assigns on the `||` branch, so on SUCCESS the variable keeps whatever it
|
|
# already held — and an EXPORTED variable of this name is inherited from the
|
|
# ambient environment. `${VAR:-0}` defaults only when unset/empty, so
|
|
# `CLASSIFY_STATUS=3 git commit …` made this hook print "validator disabled" and exit 0,
|
|
# silently accepting a non-conforming commit. Found by review of #4429.
|
|
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, msd-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 "msd-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.
|
|
# Indexed, not searched (#4492). `${CMD#*"$MSG_MATCH"}` is quadratic in
|
|
# the message: bash walks every prefix length and compares the whole
|
|
# matched literal at each one, and MSG_MATCH is BASH_REMATCH[0] — the
|
|
# entire `-m "..."` — so the cost grows with the thing being scanned.
|
|
# Measured on the path EVERY commit takes (conforming and non-conforming
|
|
# cost the same): 10.0 s at a 64 KB message, 22.0 s at 96 KB, 30.2 s at
|
|
# 112 KB. Sizes stop there deliberately — a single argument above Linux's
|
|
# MAX_ARG_STRLEN (131072 on a 4 KB-page kernel) never reaches this code
|
|
# at all, because execve fails and the hook fails open, so a larger
|
|
# "measurement" would be timing the wrong thing.
|
|
#
|
|
# MSG_PREFIX above has already located the match, so the suffix is
|
|
# arithmetic rather than a search: skip the prefix and the match. This
|
|
# removes the quadratic SEARCH; the expansion still counts characters and
|
|
# materialises a substring, so it is linear in the command, not O(1).
|
|
# Same first-occurrence assumption both expansions here always made —
|
|
# MSG_MATCH is a literal substring of CMD by construction.
|
|
MSG_SUFFIX="${CMD:$(( ${#MSG_PREFIX} + ${#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="${MSG%%$'\n'*}"
|
|
else
|
|
# Pure parameter expansion, not `echo "$MSG" | head -1`: that pipeline
|
|
# raced a SIGPIPE under `set -euo pipefail` whenever $MSG had a body
|
|
# (the common case) — `head -1` can close its read end as soon as it
|
|
# has the first line, and if `echo`'s write lands after that close,
|
|
# `echo` dies with signal 13 (exit 141), which is NOT suppressed by
|
|
# `set -e` and aborted the whole hook intermittently (observed in
|
|
# tests/hooks-opt-in.test.cjs's --fixup=HEAD "round 7" case). Zero
|
|
# subprocesses here means zero pipe/race surface. Equivalent to
|
|
# `head -1` for single-line, multi-line, and trailing-newline input.
|
|
SUBJECT="${MSG%%$'\n'*}"
|
|
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
|
|
# membership test 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_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.
|
|
#
|
|
# #4429: do NOT build `^(type1|type2|...)` out of COMMIT_TYPES. That
|
|
# alternation grows with the CONFIGURED list, and how large a pattern can be
|
|
# compiled is a property of the platform's regex engine. bash 3.2.57 / BSD
|
|
# libc (macOS, this file's stated target) caps it at 64 KiB - bisected: a
|
|
# 65504-byte alternation compiles, 65515 fails. bash 5.2 / glibc has no
|
|
# reachable cap, so this half never bit Linux. Past a cap `[[ =~ ]]`
|
|
# returns 2, and `if !` cannot tell a COMPILE ERROR from "the subject does
|
|
# not conform" - so a valid `feat(auth): ...` was blocked with
|
|
# CONVENTIONAL_COMMITS_VIOLATION while `feat` sat in its own valid_types.
|
|
#
|
|
# Match the SHAPE with a fixed-size pattern, then test membership against
|
|
# the array. The regex no longer depends on how many types are configured,
|
|
# and the loop adds no subprocess or pipe (the #4429 hazard this file
|
|
# already avoids elsewhere). The character class is exactly the safe-token
|
|
# filter `^[a-z][a-z0-9-]*$` applied above, so it captures every type that
|
|
# can legally reach COMMIT_TYPES and no token that cannot.
|
|
SUBJECT_TYPE=''
|
|
if [[ "$SUBJECT" =~ ^([a-z][a-z0-9-]*)(\(.+\))?:[[:space:]].+ ]]; then
|
|
SUBJECT_TYPE="${BASH_REMATCH[1]}"
|
|
fi
|
|
COMMIT_TYPE_OK=0
|
|
if [ -n "$SUBJECT_TYPE" ]; then
|
|
for _known_type in "${COMMIT_TYPES[@]}"; do
|
|
if [ "$_known_type" = "$SUBJECT_TYPE" ]; then COMMIT_TYPE_OK=1; break; fi
|
|
done
|
|
fi
|
|
if [ "$COMMIT_TYPE_OK" -ne 1 ]; 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
|