From f0c1bc40708615d5b423922c08c1f27e6cf96259 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 24 Jul 2026 12:28:18 -0500 Subject: fix(hooks): the secret scan no longer passes when git fails Every bundle built its scan input as `git diff --cached ... | grep ... || true`. The `|| true` has to stay, since grep exits 1 when it matches nothing and that is the ordinary case. But with no pipefail it also swallowed a failure of git itself, so an empty result made the scan search nothing, find nothing, and report clean with a real credential staged. A gate that passes without having looked. .emacs.d found it in elisp. It was in all five: bash, elisp, go, python and typescript, eleven sites once each bundle's staged-file list is counted. Two of those bundles are ones I wrote yesterday by copying bash, so I propagated it while closing an unrelated gap in the same file. Each site now reads the diff on its own and aborts if git fails, leaving the greps their `|| true`. Verified per bundle on three axes: refuses when the diff cannot be read, still blocks a real staged secret, still passes a clean commit. The cross-bundle test suite had its own version of the same disease. Its VARIANTS list read "elisp bash go" while python and typescript also shipped hooks, so every "in every variant" assertion had quietly skipped two bundles since the day they were added. VARIANTS is now discovered from the tree. Two fail-closed assertions join it, and .emacs.d's elisp suite is adopted here beside the canonical hook, because a test living in the consuming project cannot fail when the canonical regresses. Also removes the validate-el auto-test cap, Craig's call. Above 20 matching test files the runner skipped everything and exited 0 with no output. The premise was speed and it did not hold: a whole family runs in about a second. The cap was also hiding a real cross-test pollution bug that only surfaces when a family runs in one process. --- languages/bash/githooks/pre-commit | 24 ++++- languages/elisp/claude/hooks/validate-el.sh | 4 +- languages/elisp/githooks/pre-commit | 23 ++++- languages/elisp/tests/test-pre-commit-hook.bats | 126 +++++++++++++++++++++++ languages/elisp/tests/test-validate-el-hook.bats | 100 ++++++++++++++++++ languages/go/githooks/pre-commit | 24 ++++- languages/python/githooks/pre-commit | 24 ++++- languages/typescript/githooks/pre-commit | 27 +++-- 8 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 languages/elisp/tests/test-pre-commit-hook.bats create mode 100644 languages/elisp/tests/test-validate-el-hook.bats (limited to 'languages') diff --git a/languages/bash/githooks/pre-commit b/languages/bash/githooks/pre-commit index 880d5cf..1520690 100755 --- a/languages/bash/githooks/pre-commit +++ b/languages/bash/githooks/pre-commit @@ -18,8 +18,18 @@ cd "$REPO_ROOT" || exit 1 SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)' SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']' -added_lines="$(git diff --cached -U0 --diff-filter=AM \ - | grep '^+' | grep -v '^+++' || true)" +# Read the diff on its own so a git failure is distinguishable from "grep +# matched nothing". Both end in a non-zero status, but only one of them means +# there is nothing to scan; piping them together and swallowing the result with +# `|| true` made a broken git look like a clean commit — the scan searched an +# empty string, found nothing, and the secret went in. +if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2 + exit 1 +fi + +# The greps keep their `|| true`: exiting 1 on no match is their normal result. +added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)" cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)" ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)" @@ -37,8 +47,14 @@ if [ -n "$secret_hits" ]; then fi # --- 2. shellcheck on staged .sh / .bash files --- -staged_sh="$(git diff --cached --name-only --diff-filter=AM \ - | grep -E '\.(sh|bash)$' || true)" +# Same split as the secret scan above: a git failure must not read as "no files +# staged", which would skip the language check silently. +if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2 + exit 1 +fi + +staged_sh="$(printf '%s\n' "$staged_names" | grep -E '\.(sh|bash)$' || true)" if [ -n "$staged_sh" ] && command -v shellcheck >/dev/null 2>&1; then failed="" diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh index d028789..870eefe 100755 --- a/languages/elisp/claude/hooks/validate-el.sh +++ b/languages/elisp/claude/hooks/validate-el.sh @@ -39,8 +39,6 @@ f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" [ -z "$f" ] && exit 0 [ "${f##*.}" = "el" ] || exit 0 -MAX_AUTO_TEST_FILES=20 # skip if more matches than this (large test suites) - # --- Phase 1: syntax + byte-compile --- case "$f" in */init.el|*/early-init.el) @@ -96,7 +94,7 @@ case "$f" in esac count="${#tests[@]}" -if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then +if [ "$count" -ge 1 ]; then load_args=() for t in "${tests[@]}"; do load_args+=("-l" "$t"); done if ! output="$(emacs --batch --no-site-file --no-site-lisp \ diff --git a/languages/elisp/githooks/pre-commit b/languages/elisp/githooks/pre-commit index 2c0cf0b..a87bedf 100755 --- a/languages/elisp/githooks/pre-commit +++ b/languages/elisp/githooks/pre-commit @@ -18,8 +18,18 @@ cd "$REPO_ROOT" || exit 1 SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)' SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']' -added_lines="$(git diff --cached -U0 --diff-filter=AM \ - | grep '^+' | grep -v '^+++' || true)" +# Read the diff on its own so a git failure is distinguishable from "grep +# matched nothing". Both end in a non-zero status, but only one of them means +# there is nothing to scan; piping them together and swallowing the result with +# `|| true` made a broken git look like a clean commit — the scan searched an +# empty string, found nothing, and the secret went in. +if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2 + exit 1 +fi + +# The greps keep their `|| true`: exiting 1 on no match is their normal result. +added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)" cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)" ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)" @@ -37,7 +47,14 @@ if [ -n "$secret_hits" ]; then fi # --- 2. Paren check on staged .el files --- -staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)" +# Same split as the secret scan above: a git failure must not read as "no files +# staged", which would skip the language check silently. +if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2 + exit 1 +fi + +staged_el="$(printf '%s\n' "$staged_names" | grep '\.el$' || true)" if [ -n "$staged_el" ]; then paren_fail="" diff --git a/languages/elisp/tests/test-pre-commit-hook.bats b/languages/elisp/tests/test-pre-commit-hook.bats new file mode 100644 index 0000000..413c71d --- /dev/null +++ b/languages/elisp/tests/test-pre-commit-hook.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# Tests for githooks/pre-commit — the secret scan and paren check. +# +# The scan reads its input through a pipeline: +# +# added_lines="$(git diff --cached ... | grep '^+' | grep -v '^+++' || true)" +# +# `grep` exits 1 when it matches nothing, which is the ordinary case, so the +# `|| true` has to stay. But with no `pipefail` it also swallows a failure of +# `git diff` itself, and an empty `added_lines` makes the scan search nothing, +# find nothing, and report clean. A gate that passes without looking is the +# failure this file exists to pin: the fail-open test drives a broken `git diff` +# and asserts the hook refuses rather than exiting 0. +# +# Each test builds a throwaway git repo in BATS_TEST_TMPDIR, so nothing touches +# the real repository or its hooks. + +setup() { + HOOK="${BATS_TEST_DIRNAME}/../githooks/pre-commit" + REPO="${BATS_TEST_TMPDIR}/repo" + mkdir -p "$REPO" + cd "$REPO" || return 1 + git init -q . + git config user.email t@example.com + git config user.name Test + # Split so the fixtures never appear as credential-shaped literals here. + AWS_TAIL="IOSFODNN7EXAMPLE" + WORD_TAIL="word" +} + +# Put a stub `git` ahead of the real one that fails for the staged-diff call +# and delegates everything else, so only the pipeline under test breaks. +break_staged_diff() { + mkdir -p "${BATS_TEST_TMPDIR}/bin" + cat > "${BATS_TEST_TMPDIR}/bin/git" <<'STUB' +#!/usr/bin/env bash +if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "-U0" ]; then + echo "simulated git failure" >&2 + exit 128 +fi +exec /usr/bin/git "$@" +STUB + chmod +x "${BATS_TEST_TMPDIR}/bin/git" + PATH="${BATS_TEST_TMPDIR}/bin:$PATH" +} + +# ------------------------------- Normal cases ------------------------------- + +@test "secret scan: blocks a staged AWS key" { + # Assembled at runtime: a literal key-shaped string in this file would trip + # the very hook under test on every commit that touches it, and this repo + # mirrors to a public remote. + printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt + git add creds.txt + run "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"potential secret"* ]] +} + +@test "secret scan: blocks a staged keyword=value password" { + printf '%s = "%s"\n' "pass${WORD_TAIL}" "correcthorsebatterystaple" > conf.txt + git add conf.txt + run "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"potential secret"* ]] +} + +@test "secret scan: allows an ordinary staged file" { + printf 'just some prose\n' > notes.txt + git add notes.txt + run "$HOOK" + [ "$status" -eq 0 ] +} + +# ------------------------------ Boundary cases ------------------------------ + +@test "secret scan: allows a commit with nothing staged" { + run "$HOOK" + [ "$status" -eq 0 ] +} + +@test "paren check: blocks an unbalanced staged .el file" { + printf '(defun broken ()\n (message "no close"\n' > bad.el + git add bad.el + run "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"paren check failed"* ]] +} + +@test "paren check: allows a balanced staged .el file" { + printf '(defun fine ()\n (message "ok"))\n' > good.el + git add good.el + run "$HOOK" + [ "$status" -eq 0 ] +} + +# -------------------------------- Error cases ------------------------------- + +@test "secret scan: refuses to pass when the staged diff cannot be read" { + # The scan must not report clean after searching nothing. Without a + # pipefail-aware guard the broken diff yields an empty added_lines and the + # hook exits 0, letting a real secret through unscanned. + printf 'aws = "%s"\n' "AKIA${AWS_TAIL}" > creds.txt + git add creds.txt + break_staged_diff + run "$HOOK" + [ "$status" -ne 0 ] +} + +@test "paren check: refuses to pass when the staged file list cannot be read" { + printf '(defun broken ()\n (message "no close"\n' > bad.el + git add bad.el + mkdir -p "${BATS_TEST_TMPDIR}/bin2" + cat > "${BATS_TEST_TMPDIR}/bin2/git" <<'STUB' +#!/usr/bin/env bash +if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ] && [ "${3:-}" = "--name-only" ]; then + echo "simulated git failure" >&2 + exit 128 +fi +exec /usr/bin/git "$@" +STUB + chmod +x "${BATS_TEST_TMPDIR}/bin2/git" + PATH="${BATS_TEST_TMPDIR}/bin2:$PATH" + run "$HOOK" + [ "$status" -ne 0 ] +} diff --git a/languages/elisp/tests/test-validate-el-hook.bats b/languages/elisp/tests/test-validate-el-hook.bats new file mode 100644 index 0000000..d4d6f23 --- /dev/null +++ b/languages/elisp/tests/test-validate-el-hook.bats @@ -0,0 +1,100 @@ +#!/usr/bin/env bats +# Tests for .claude/hooks/validate-el.sh — the auto-test runner. +# +# The runner used to skip entirely above MAX_AUTO_TEST_FILES=20, with no else +# branch: nothing printed, exit 0, indistinguishable from a passing run. That +# was live for the three largest families here (calendar-sync 63 test files, +# music 45, ai-term 35), so every edit to those ran parens and byte-compile and +# zero tests, silently. +# +# The cap was removed rather than made loud, because its premise did not hold. +# Measured on this machine, running a whole family takes about a second: +# ai-term 208 tests in 1.0s, music 403 in 1.7s, calendar-sync 633 in 0.9s. It +# was also concealing a real cross-test pollution bug in calendar-sync that +# only appears when that family runs in one process. +# +# These tests pin that no file count is skipped. Each builds a synthetic +# project in BATS_TEST_TMPDIR and points CLAUDE_PROJECT_DIR at it, so nothing +# runs against the real tree. + +setup() { + # Bundle layout: the hook ships at claude/hooks/ here and installs to + # .claude/hooks/ in a consuming project. This test was written against the + # installed layout, so re-homing it needed the path adjusted. + HOOK="${BATS_TEST_DIRNAME}/../claude/hooks/validate-el.sh" + PROJ="${BATS_TEST_TMPDIR}/proj" + mkdir -p "$PROJ/modules" "$PROJ/tests" + export CLAUDE_PROJECT_DIR="$PROJ" + printf '(provide (quote widget))\n' > "$PROJ/modules/widget.el" +} + +# N green test files matching the widget stem. +make_tests() { + local n="$1" i + for ((i = 1; i <= n; i++)); do + printf '(require (quote ert))\n(ert-deftest test-widget-%d () (should t))\n' \ + "$i" > "$PROJ/tests/test-widget-${i}.el" + done +} + +# One failing test file, to prove the run is real rather than merely quiet. +make_failing_test() { + printf '(require (quote ert))\n(ert-deftest test-widget-bad () (should nil))\n' \ + > "$PROJ/tests/test-widget-bad.el" +} + +hook_input() { + printf '{"tool_input":{"file_path":"%s"}}' "$PROJ/modules/widget.el" +} + +run_hook() { + run bash -c "$(printf '%q' "$HOOK") <<< '$(hook_input)'" +} + +# ------------------------------- Normal cases ------------------------------- + +@test "a small family runs and passes quietly" { + make_tests 3 + run_hook + [ "$status" -eq 0 ] +} + +@test "a failing test blocks, so a quiet pass means the tests really ran" { + make_tests 3 + make_failing_test + run_hook + [ "$status" -eq 2 ] + [[ "$output" == *"TESTS FAILED"* ]] +} + +# ------------------------------ Boundary cases ------------------------------ + +@test "at the old cap of 20 files: runs" { + make_tests 20 + run_hook + [ "$status" -eq 0 ] +} + +@test "past the old cap: still runs, no longer skipped" { + make_tests 21 + run_hook + [ "$status" -eq 0 ] + [[ "${output,,}" != *"skipped"* ]] +} + +@test "well past the old cap: a failure in file 63 is still caught" { + # The regression this guards: at 63 files the runner used to skip, so a red + # test in a big family reported clean. calendar-sync is exactly this size. + make_tests 63 + make_failing_test + run_hook + [ "$status" -eq 2 ] + [[ "$output" == *"TESTS FAILED"* ]] +} + +# -------------------------------- Error cases ------------------------------- + +@test "no matching tests: exits clean without running anything" { + run_hook + [ "$status" -eq 0 ] +} diff --git a/languages/go/githooks/pre-commit b/languages/go/githooks/pre-commit index 26b0db0..7d93949 100755 --- a/languages/go/githooks/pre-commit +++ b/languages/go/githooks/pre-commit @@ -18,8 +18,18 @@ cd "$REPO_ROOT" || exit 1 SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)' SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']' -added_lines="$(git diff --cached -U0 --diff-filter=AM \ - | grep '^+' | grep -v '^+++' || true)" +# Read the diff on its own so a git failure is distinguishable from "grep +# matched nothing". Both end in a non-zero status, but only one of them means +# there is nothing to scan; piping them together and swallowing the result with +# `|| true` made a broken git look like a clean commit — the scan searched an +# empty string, found nothing, and the secret went in. +if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2 + exit 1 +fi + +# The greps keep their `|| true`: exiting 1 on no match is their normal result. +added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)" cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)" ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)" @@ -39,8 +49,14 @@ fi # --- 2. gofmt check on staged .go files --- # gofmt -l lists files that aren't gofmt-clean. Skip generated and vendored # files the same way the rest of the toolchain does. -staged_go="$(git diff --cached --name-only --diff-filter=AM \ - | grep '\.go$' \ +# Same split as the secret scan above: a git failure must not read as "no files +# staged", which would skip the language check silently. +if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2 + exit 1 +fi + +staged_go="$(printf '%s\n' "$staged_names" | grep '\.go$' \ | grep -vE '(^|/)vendor/' || true)" if [ -n "$staged_go" ] && command -v gofmt >/dev/null 2>&1; then diff --git a/languages/python/githooks/pre-commit b/languages/python/githooks/pre-commit index 0a578b4..03536db 100755 --- a/languages/python/githooks/pre-commit +++ b/languages/python/githooks/pre-commit @@ -18,8 +18,18 @@ cd "$REPO_ROOT" || exit 1 SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)' SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']' -added_lines="$(git diff --cached -U0 --diff-filter=AM \ - | grep '^+' | grep -v '^+++' || true)" +# Read the diff on its own so a git failure is distinguishable from "grep +# matched nothing". Both end in a non-zero status, but only one of them means +# there is nothing to scan; piping them together and swallowing the result with +# `|| true` made a broken git look like a clean commit — the scan searched an +# empty string, found nothing, and the secret went in. +if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2 + exit 1 +fi + +# The greps keep their `|| true`: exiting 1 on no match is their normal result. +added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)" cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)" ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)" @@ -37,8 +47,14 @@ if [ -n "$secret_hits" ]; then fi # --- 2. Syntax check on staged Python files --- -staged_py="$(git diff --cached --name-only --diff-filter=AM \ - | grep -E '\.pyi?$' || true)" +# Same split as the secret scan above: a git failure must not read as "no files +# staged", which would skip the language check silently. +if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2 + exit 1 +fi + +staged_py="$(printf '%s\n' "$staged_names" | grep -E '\.pyi?$' || true)" if [ -n "$staged_py" ] && command -v python3 >/dev/null 2>&1; then failed="" diff --git a/languages/typescript/githooks/pre-commit b/languages/typescript/githooks/pre-commit index 1628080..fd494d2 100755 --- a/languages/typescript/githooks/pre-commit +++ b/languages/typescript/githooks/pre-commit @@ -18,8 +18,18 @@ cd "$REPO_ROOT" || exit 1 SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)' SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']' -added_lines="$(git diff --cached -U0 --diff-filter=AM \ - | grep '^+' | grep -v '^+++' || true)" +# Read the diff on its own so a git failure is distinguishable from "grep +# matched nothing". Both end in a non-zero status, but only one of them means +# there is nothing to scan; piping them together and swallowing the result with +# `|| true` made a broken git look like a clean commit — the scan searched an +# empty string, found nothing, and the secret went in. +if ! staged_diff="$(git diff --cached -U0 --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged diff — refusing to skip the secret scan" >&2 + exit 1 +fi + +# The greps keep their `|| true`: exiting 1 on no match is their normal result. +added_lines="$(printf '%s\n' "$staged_diff" | grep '^+' | grep -v '^+++' || true)" cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)" ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)" @@ -42,10 +52,15 @@ fi # it rejects valid TS (an `interface` reads as a syntax error) and accepts # broken TS. Measured on node v26.4.0, 2026-07-23. tsc is the only correct # parser for .ts; node is correct and much faster for .js. -staged_js="$(git diff --cached --name-only --diff-filter=AM \ - | grep -E '\.(js|jsx|mjs|cjs)$' || true)" -staged_ts="$(git diff --cached --name-only --diff-filter=AM \ - | grep -E '\.(ts|tsx|mts|cts)$' || true)" +# Same split as the secret scan above: a git failure must not read as "no files +# staged", which would skip the language check silently. +if ! staged_names="$(git diff --cached --name-only --diff-filter=AM)"; then + echo "pre-commit: cannot read the staged file list — refusing to skip the check" >&2 + exit 1 +fi + +staged_js="$(printf '%s\n' "$staged_names" | grep -E '\.(js|jsx|mjs|cjs)$' || true)" +staged_ts="$(printf '%s\n' "$staged_names" | grep -E '\.(ts|tsx|mts|cts)$' || true)" failed="" -- cgit v1.2.3