diff --git a/.github/workflows/hooks-tests.yml b/.github/workflows/hooks-tests.yml index 046bad6..0bec382 100644 --- a/.github/workflows/hooks-tests.yml +++ b/.github/workflows/hooks-tests.yml @@ -2,8 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 # # Run the bats suite over the canonical git hooks. The hook templates live -# in resq-software/crates; the suite fetches them at bats init time and -# tests behavior (here-string safety, local-* dispatch, stdin propagation, +# in resq-software/crates; the suite fetches them at bats init time — from the +# commit scripts/install-hooks.sh pins, so it tests what users are installed — +# and tests behavior (here-string safety, local-* dispatch, stdin propagation, # GIT_HOOKS_SKIP, etc). name: hooks-tests @@ -49,3 +50,26 @@ jobs: - name: Print bats version (diagnostic) if: always() run: bats --version + + # The job above tests the crates commit install-hooks.sh pins — what users + # actually get. This one tests crates master, so a template change that will + # break the suite shows up before the next pin bump rather than during it. + # Schedule/dispatch only: upstream drift is not something a dev PR can fix. + bats-upstream: + name: bats (crates master) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install bats + run: | + sudo apt-get update -y + sudo apt-get install -y bats + + - name: Run hook test suite against crates master + env: + RESQ_HOOK_REF: master + run: bats tests/hooks/ diff --git a/AGENTS.md b/AGENTS.md index c1dbc42..9f2a629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,8 +161,11 @@ its canonical checks. Commit `local-*` files in the repo needing extras (e.g. `local-pre-push` running `cargo check`). The canonical hooks themselves are managed by `install-hooks.sh` and should not be hand-edited. -**`resq` backend**: hooks soft-skip with an informative warning if `resq` is -not on PATH. Provide it either via your repo's `flake.nix` (recommended — add +**`resq` backend**: `pre-commit` **fails closed** if `resq` is not on PATH — +it refuses the commit and lists every check that could not run, because an +unscanned change must not pass by default. (Hooks before +resq-software/crates#206 soft-skipped instead.) To commit without it on purpose, +use `GIT_HOOKS_SKIP=all` or `--no-verify`. Provide it either via your repo's `flake.nix` (recommended — add `resq-software/crates` as an input and include the `resq` package in `devPackages`) or globally: @@ -170,7 +173,56 @@ not on PATH. Provide it either via your repo's `flake.nix` (recommended — add cargo install --git https://github.com/resq-software/crates resq-cli ``` -**Bypass**: `git commit --no-verify`, `git push --no-verify`, or -`GIT_HOOKS_SKIP=1` in the environment to disable all hooks for a session. +**Bypass**: `git commit --no-verify` / `git push --no-verify` skips one +invocation. `GIT_HOOKS_SKIP` skips named checks for a session. + +`GIT_HOOKS_SKIP` is a **list of check names, not a boolean**. Separate with +commas, colons or spaces; case-insensitive. + +| Token | Skips | +|---|---| +| `all`, `1`, `true`, `yes`, `on` | **every check in every hook** except `prepare-commit-msg` | +| `0`, `false`, `no`, `off`, `none` | nothing — explicit no-op | +| `audit` / `format` / `versioning` | `pre-commit` steps | +| `msg-format` / `wip-guard` | `commit-msg` checks | +| `force-push` / `branch-name` | `pre-push` guards | +| `notify` | `post-checkout`, `post-merge` lock-file notices | +| `local` | the repo's `local-` override (not `local-prepare-commit-msg`) | + +**Exception:** `prepare-commit-msg` has no `GIT_HOOKS_SKIP` handling at the +pinned crates commit, so no token changes what it does. It exits first, doing +nothing, for message sources `message` (`-m`/`-F`), `merge`, `squash` and +`commit` (`-c`/`-C`/`--amend`), and on a detached HEAD. Otherwise it adds a +`[TICKET-123]` prefix when the branch name carries one and dispatches to +`local-prepare-commit-msg`. It prints no banner and gates nothing. + +```sh +GIT_HOOKS_SKIP=audit git commit -m "..." # audit off; secret scan still runs +GIT_HOOKS_SKIP=audit,format git commit -m "..." +``` + +Three rules matter more than the token list: + +1. **A skip is announced, never silent.** Before running anything the hook + prints to stderr what it SKIPPED and what it is still RUNNING. Treat that + banner as the source of truth for what actually executed — if a check isn't + listed under `RUNNING`, do not report it as passed. When the value disables + nothing in that hook, the banner is a single line instead (`… is set but + disables nothing in — all checks ran`), and every check ran. +2. **An unrecognized value fails closed.** `GIT_HOOKS_SKIP=asdf` does not mean + "skip everything"; gating hooks refuse to run and print the valid tokens. + The old guard was `[ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0`, so any non-empty + value disabled the whole hook — a granular-looking `GIT_HOOKS_SKIP=audit` + silently turned off the secret scan as well. That is the bug this replaces. +3. **The secret scan has no token.** It is the compensating control for + unlicensed GitHub Secret Protection, so it can only be disabled by the + all-off value (which says so loudly) or `--no-verify`. `GIT_HOOKS_SKIP=secrets` + is refused by name. + +The token vocabulary is global and byte-identical in every hook that parses it +(all but `prepare-commit-msg`), so a value +exported once for a shell session cannot mean different things in different +hooks. A token owned by another hook is inert, not an error. Behaviour is +pinned by `tests/hooks/git-hooks-skip.bats`. Sibling repos' `AGENTS.md` should link this section rather than duplicating it. diff --git a/README.md b/README.md index ba6d70c..3f9e931 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,10 @@ these digests as an integrity check, not proof of origin. Common env vars across all of them: - `YES=1` — skip prompts (CI / provisioning) -- `GIT_HOOKS_SKIP=1` — disable installed hooks for a session +- `GIT_HOOKS_SKIP=` — skip named hook checks. **Not a boolean** — `1` (or + `all`) disables *every* check, while granular tokens like `audit` or + `audit,format` disable only those. An unrecognized value fails closed instead + of skipping, and every skip is announced. See [below](#bypassing-a-check) - `RESQ_SKIP_LOCAL_SCAFFOLD=1` — opt out of the `local-pre-push` scaffold prompt To pin a revision, use a version-locked URL rather than an environment @@ -309,6 +312,38 @@ resq hooks scaffold-local --kind auto # detects rust/python/node/dotnet/cpp/n `resq hooks doctor` reports drift, `resq hooks update` re-syncs from the embedded canonical, `resq hooks status` prints a one-line shell-friendly summary. +#### Bypassing a check + +`GIT_HOOKS_SKIP` takes a **list of check names**, not a boolean. Separate them with commas, colons or spaces; case doesn't matter. + +| Value | Skips | +|---|---| +| `all`, `1`, `true`, `yes`, `on` | **Everything.** The whole hook is disabled (every hook but `prepare-commit-msg`) | +| `0`, `false`, `no`, `off`, `none` | Nothing — an explicit no-op (under the old guard, `0` skipped *everything*) | +| `audit` | `pre-commit` — security audit (osv-scanner / audit-ci) | +| `format` | `pre-commit` — code formatters | +| `versioning` | `pre-commit` — changeset / version prompt | +| `msg-format` | `commit-msg` — Conventional Commits subject check | +| `wip-guard` | `commit-msg` — `WIP:` / `fixup!` / `squash!` block on main | +| `force-push` | `pre-push` — force-push guard on main/master | +| `branch-name` | `pre-push` — branch naming convention | +| `notify` | `post-checkout` / `post-merge` — lock-file drift notices | +| `local` | any hook but `prepare-commit-msg` — the repo's `local-` override | + +```bash +GIT_HOOKS_SKIP=audit git commit -m "chore: lockfile bump" # audit off, secret scan still runs +GIT_HOOKS_SKIP=audit,format git commit -m "..." # both off +``` + +Three properties are deliberate, and worth knowing before you rely on this: + +- **Every skip is announced.** The hook prints, on stderr, what it skipped *and what it still ran*, before running anything. If you didn't see the banner, nothing was skipped. Don't record a check as passing that the banner didn't list under `RUNNING`. If the value disables nothing in that hook, the banner is one line saying so (`… disables nothing in — all checks ran`), and every check ran. +- **`prepare-commit-msg` is the exception.** At the pinned crates commit it has no `GIT_HOOKS_SKIP` handling, so no token changes what it does. It does nothing for `-m`/`-F` messages, merges, squashes, `-c`/`-C`/`--amend`, or on a detached HEAD; otherwise it adds a ticket prefix when the branch name carries one and runs `local-prepare-commit-msg`, with no banner. It gates nothing. +- **An unrecognized value fails closed.** `GIT_HOOKS_SKIP=asdf` does not mean "skip everything" — gating hooks refuse to run and print the valid tokens. This is the incident this design exists to prevent: the old guard was `[ -n "$GIT_HOOKS_SKIP" ] && exit 0`, so *any* non-empty value silently disabled the entire hook. A plausible-looking `GIT_HOOKS_SKIP=audit` turned off the secret scan too, with nothing on screen to say so. +- **The secret scan has no token of its own.** It's the compensating control for not licensing GitHub Secret Protection, so it is reachable only through the all-off value — which shouts about it — or `git commit --no-verify`. `GIT_HOOKS_SKIP=secrets` is refused by name. + +To bypass a single invocation entirely, prefer git's own flag: `git commit --no-verify` / `git push --no-verify`. + The canonical content lives in exactly one place: [`crates/resq-cli/templates/git-hooks/`](https://github.com/resq-software/crates/tree/master/crates/resq-cli/templates/git-hooks). The crates repo's own `.git-hooks/` (for dog-fooding) is kept identical via `hooks-sync.yml`. The `dev/` repo used to ship a third copy and was retired in Phase 4 — `install-hooks.sh` now fetches from the crates source (or lets `resq hooks install` do it offline). Bats + Rust integration tests cover the hook behavior end-to-end. ## 📄 License diff --git a/VERSION b/VERSION index 0bfccb0..ef52a64 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.5 +0.4.6 diff --git a/install.ps1 b/install.ps1 index 451f123..da4622e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -33,7 +33,7 @@ $ErrorActionPreference = 'Stop' # GENERATED — stamped from VERSION by bin/stamp.sh. Do not edit by hand: CI # re-runs the stamper and fails if the committed value differs. -$ScriptVersion = '0.4.5' +$ScriptVersion = '0.4.6' $Org = 'resq-software' # Pinned to a version rather than the rolling endpoint, and digest-checked # before it runs — mirrors install.sh. required.yml re-checks the digest against @@ -48,7 +48,7 @@ $DistBase = 'https://get.resq.software' # GENERATED — SHA-256 of scripts/install-hooks.ps1 at this version, checked # before that file is ever executed. Stamped by bin/stamp.sh. -$HooksSha256 = '816581068a9c0a2b88b799b3f081bdd4b1600840d10784d51ae2c8fb8ab1ccbc' +$HooksSha256 = 'b80649d10eb1e18088142f3df23d168e448791e8f8ea1fca0d6dd4a84a5c3c20' # Canonical repo table — one source of truth for the menu, validation and the # post-install summary. Those used to be three separate lists that could diff --git a/install.sh b/install.sh index 2e7ae87..48a1822 100755 --- a/install.sh +++ b/install.sh @@ -44,7 +44,7 @@ fi # install.sh keeps verifying against the digest it actually shipped with rather # than against whatever happens to be current. release.yml refuses to publish a # tag whose version disagrees with this value. -SCRIPT_VERSION="0.4.5" +SCRIPT_VERSION="0.4.6" # Everything this script creates belongs to the invoking user alone. Set before # the first mkdir/mktemp so nothing is even briefly group- or world-readable. @@ -86,7 +86,7 @@ DIST_BASE="https://get.resq.software" # the hook installer. A compromised endpoint still cannot get code past it. # Regenerate with: # git cat-file blob "$(git rev-parse "v$SCRIPT_VERSION"):scripts/install-hooks.sh" | sha256sum -HOOKS_SHA256="c5aa6c94eca52d384d6c7a78c786aecdeaf8f76d69db908d5d2439ba31859065" +HOOKS_SHA256="9e1afcf2858351ef180bf63c29c23769294c29ebf3f886f037cef9193d868488" # Canonical repo table — one source of truth for the menu, REPO validation, and # the post-install summary. Those used to be three separate lists that could diff --git a/scripts/install-hooks.ps1 b/scripts/install-hooks.ps1 index 72b9892..22e4191 100644 --- a/scripts/install-hooks.ps1 +++ b/scripts/install-hooks.ps1 @@ -67,10 +67,37 @@ if ($onPath) { $resqBin = Join-Path $HOME '.cargo/bin/resq.exe' } +# ── Is that resq new enough to supply the hooks? ──────────────────────────── +# Path 1 installs the templates EMBEDDED in the binary, so an old resq installs +# old hooks. Before resq-cli 0.4.3 (resq-software/crates#206) they carried the +# blanket GIT_HOOKS_SKIP guard, under which a granular-looking +# GIT_HOOKS_SKIP=audit also disabled the secret scan. A binary below this +# floor, or one whose version cannot be read, gets the pinned, digest-verified +# templates from path 2 instead. Keep in step with install-hooks.sh. +$resqMinHooksVersion = [version]'0.4.3' +$resqTemplatesOk = $false +if ($resqBin) { + $resqVersion = $null + # A binary that cannot even launch (wrong architecture, corrupt file) throws + # under ErrorActionPreference=Stop; treat that as an unreadable version so + # the verified fallback runs instead of the installer aborting. + $versionLine = $null + try { $versionLine = (& $resqBin --version 2>$null | Select-Object -First 1) } catch { $versionLine = $null } + if ("$versionLine" -match '^resq\S* v?(\d+\.\d+\.\d+)') { $resqVersion = [version]$Matches[1] } + if ($resqVersion -and $resqVersion -ge $resqMinHooksVersion) { + $resqTemplatesOk = $true + } else { + $shown = if ($resqVersion) { "$resqVersion" } else { '' } + Write-Host "warn $resqBin reports version $shown; its embedded hooks predate the granular" -ForegroundColor Yellow + Write-Host " GIT_HOOKS_SKIP fix (needs >= $resqMinHooksVersion). Installing the pinned, verified hooks" -ForegroundColor Yellow + Write-Host " instead. Upgrade resq too - the new hooks may pass it flags it lacks." -ForegroundColor Yellow + } +} + # ── Path 1: use resq when present (preferred — offline, no raw fetch) ─────── # Prefer the new `hooks install` path; fall back to `dev install-hooks` # for binaries built before resq-software/crates#60. -if ($resqBin) { +if ($resqTemplatesOk) { & $resqBin hooks install --help *> $null $installArgs = if ($LASTEXITCODE -eq 0) { @('hooks', 'install') } else { @('dev', 'install-hooks') } Write-Host "info Installing hooks via $resqBin $($installArgs -join ' ')" -ForegroundColor Cyan @@ -91,14 +118,14 @@ if ($resqBin) { # Pinned commit in resq-software/crates. Keep in step with the digests below # and with scripts/install-hooks.sh; required.yml re-checks all three # against the live endpoint, so drift fails CI rather than a user's install. - $cratesCommit = 'd84cd9613da685ffb772a766673d6ba6a4acaf31' + $cratesCommit = '72e0ae4952624ccd5cf39adc632e15b3d91b86c9' $hookDigests = @{ - 'pre-commit' = '540c19f96fe258df6d90a8357b27513657b380567650ce3aaed83efa216a64ee' - 'commit-msg' = 'e7fa95d5d54f212ef145ae9d53cae08b24b0f9dbd5501fde1dae0aa4ddfeb2a6' + 'pre-commit' = 'fd2d275571d431a8cb897a047176f0da9a096ef76a111a0ef0147bb10ba83ffc' + 'commit-msg' = 'd33ecc52661d43aabaeae1d789df04c709ece41e37fd224b6940c7639ac2a6ef' 'prepare-commit-msg' = '4fa2e7abf284adc93da750b9c4387de781dd552874290c81885c5dc19debe99b' - 'pre-push' = '85677f87b30220a443e00191e8267998f62c83642127311c72efe841aab72c79' - 'post-checkout' = '2a894cf301bd487494d57f39159de07b79f608beeece349de5a4bdc0b5a11480' - 'post-merge' = 'b05e2ecacb5c342b3fc3525987ad73dc700079bfe7fd5bf7eac67461ea77cfbb' + 'pre-push' = '84f1d08fa54baa592d5cc3519ac85cba69d59bc1d502ca8d821cb8f30dde53ce' + 'post-checkout' = 'aeacd20d8d42d75586f147f8cf92d5ae68eb0e7c9fbe99f7cd1838904f943180' + 'post-merge' = '32d3e73e5b894b7a42c21075192262997e7a953b84a18f89901cf579a431ab2c' } # -Ref still works, but pinned digests cannot describe an arbitrary ref, so @@ -162,7 +189,16 @@ if ($resqBin) { Write-Host " ok ResQ hooks installed in $hooksDir" -ForegroundColor Green Write-Host " Bypass once: git commit --no-verify" -Write-Host " Disable all hooks: `$env:GIT_HOOKS_SKIP = '1'" +Write-Host " Skip one check: `$env:GIT_HOOKS_SKIP = 'audit' (or format, versioning," +Write-Host " msg-format, wip-guard, force-push, branch-name, notify," +Write-Host " local - comma-separate to combine)" +Write-Host " Disable ALL checks: `$env:GIT_HOOKS_SKIP = 'all'" +Write-Host " GIT_HOOKS_SKIP is a list of check names, not a boolean." +Write-Host " An unrecognised value fails closed, and every skip is" +Write-Host " announced - if you saw no banner, nothing was skipped." +Write-Host " The secret scan has no token; only the all-off value" +Write-Host " (all/1/true/yes/on) or --no-verify disables it." +Write-Host " prepare-commit-msg ignores GIT_HOOKS_SKIP entirely." Write-Host " Add repo logic: $hooksDir/local-" # `return`, not `exit`. install.ps1 runs this file as a ScriptBlock @@ -175,9 +211,12 @@ Write-Host " Add repo logic: $hooksDir/local-" # install. The common path therefore truncated the installer here — no resq # CLI, no completions, no "Ready!" banner, no next steps — and exited 0. if (-not $resqBin) { - Write-Host "warn resq backend not found. Hooks will soft-skip until you install it:" -ForegroundColor Yellow + # The installed pre-commit fails closed without resq (see install-hooks.sh). + Write-Host "warn resq backend not found. Until it is installed, pre-commit REFUSES every" -ForegroundColor Yellow + Write-Host " commit (no checks can run, so none are waived). Install it:" -ForegroundColor Yellow Write-Host " irm https://raw.githubusercontent.com/resq-software/dev/main/scripts/install-resq.sh | sh" Write-Host " (or) cargo install --git https://github.com/resq-software/crates resq-cli" + Write-Host " To commit without it on purpose: `$env:GIT_HOOKS_SKIP = 'all', or git commit --no-verify." return } diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index f730980..751faf9 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -23,7 +23,6 @@ # # Env: # RESQ_CRATES_REF — git ref for raw fallback (default: master) -# GIT_HOOKS_SKIP — set to skip installation entirely # YES=1 — auto-accept the local-hook scaffold prompt # RESQ_SKIP_LOCAL_SCAFFOLD — set to opt out of the local-hook prompt @@ -48,10 +47,48 @@ elif [ -x "$HOME/.cargo/bin/resq" ]; then RESQ_BIN="$HOME/.cargo/bin/resq" fi +# ── Is that resq new enough to supply the hooks? ──────────────────────────── +# Path 1 installs the templates EMBEDDED in the binary, so an old resq installs +# old hooks. Before resq-cli 0.4.3 (resq-software/crates#206) they carried the +# blanket `[ -n "$GIT_HOOKS_SKIP" ] && exit 0` guard, under which a +# granular-looking GIT_HOOKS_SKIP=audit also disabled the secret scan. A binary +# below this floor, or one whose version cannot be read, gets the pinned, +# digest-verified templates from path 2 instead. 0.4.3 through the pinned +# 0.5.2 embed templates byte-identical to the pinned ones. +RESQ_MIN_HOOKS_VERSION="0.4.3" + +# version_at_least — numeric X.Y.Z comparison, POSIX sh. +version_at_least() { + _va_have="$1"; _va_want="$2" + for _va_i in 1 2 3; do + _va_h="$(printf '%s' "$_va_have" | cut -d. -f"$_va_i")" + _va_w="$(printf '%s' "$_va_want" | cut -d. -f"$_va_i")" + [ "${_va_h:-0}" -gt "${_va_w:-0}" ] && return 0 + [ "${_va_h:-0}" -lt "${_va_w:-0}" ] && return 1 + done + return 0 +} + +RESQ_TEMPLATES_OK=0 +if [ -n "$RESQ_BIN" ]; then + resq_version="$("$RESQ_BIN" --version 2>/dev/null | head -1 \ + | sed -n 's/^resq[^ ]* v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p')" + if [ -n "$resq_version" ] && version_at_least "$resq_version" "$RESQ_MIN_HOOKS_VERSION"; then + RESQ_TEMPLATES_OK=1 + else + printf 'warn %s reports version %s; its embedded hooks predate the granular\n' \ + "$RESQ_BIN" "${resq_version:-}" >&2 + printf ' GIT_HOOKS_SKIP fix (needs >= %s). Installing the pinned, verified hooks\n' \ + "$RESQ_MIN_HOOKS_VERSION" >&2 + printf ' instead. Upgrade resq too — the new hooks may pass it flags it lacks:\n' >&2 + printf ' curl -fsSL https://raw.githubusercontent.com/resq-software/dev/main/scripts/install-resq.sh | sh\n' >&2 + fi +fi + # ── Path 1: use resq when present (preferred — offline, no raw fetch) ─────── # Prefer the new `hooks install` path; fall back to `dev install-hooks` for # binaries built before resq-software/crates#60. -if [ -n "$RESQ_BIN" ]; then +if [ "$RESQ_TEMPLATES_OK" = 1 ]; then if "$RESQ_BIN" hooks install --help >/dev/null 2>&1; then install_cmd="hooks install" else @@ -78,16 +115,16 @@ else # Pinned commit in resq-software/crates. Update this and the digests below # together; .github/workflows/required.yml re-checks them against the live # endpoint, so a stale or mistyped pin fails CI rather than a user's install. - CRATES_COMMIT="d84cd9613da685ffb772a766673d6ba6a4acaf31" + CRATES_COMMIT="72e0ae4952624ccd5cf39adc632e15b3d91b86c9" hook_digest() { case "$1" in - pre-commit) echo "540c19f96fe258df6d90a8357b27513657b380567650ce3aaed83efa216a64ee" ;; - commit-msg) echo "e7fa95d5d54f212ef145ae9d53cae08b24b0f9dbd5501fde1dae0aa4ddfeb2a6" ;; + pre-commit) echo "fd2d275571d431a8cb897a047176f0da9a096ef76a111a0ef0147bb10ba83ffc" ;; + commit-msg) echo "d33ecc52661d43aabaeae1d789df04c709ece41e37fd224b6940c7639ac2a6ef" ;; prepare-commit-msg) echo "4fa2e7abf284adc93da750b9c4387de781dd552874290c81885c5dc19debe99b" ;; - pre-push) echo "85677f87b30220a443e00191e8267998f62c83642127311c72efe841aab72c79" ;; - post-checkout) echo "2a894cf301bd487494d57f39159de07b79f608beeece349de5a4bdc0b5a11480" ;; - post-merge) echo "b05e2ecacb5c342b3fc3525987ad73dc700079bfe7fd5bf7eac67461ea77cfbb" ;; + pre-push) echo "84f1d08fa54baa592d5cc3519ac85cba69d59bc1d502ca8d821cb8f30dde53ce" ;; + post-checkout) echo "aeacd20d8d42d75586f147f8cf92d5ae68eb0e7c9fbe99f7cd1838904f943180" ;; + post-merge) echo "32d3e73e5b894b7a42c21075192262997e7a953b84a18f89901cf579a431ab2c" ;; *) echo "" ;; esac } @@ -194,12 +231,27 @@ fi printf ' ok ResQ hooks installed in %s\n' "$HOOKS_DIR" >&2 printf ' Bypass once: git commit --no-verify\n' >&2 -printf ' Disable all hooks: export GIT_HOOKS_SKIP=1\n' >&2 +printf ' Skip one check: export GIT_HOOKS_SKIP=audit (or format, versioning,\n' >&2 +printf ' msg-format, wip-guard, force-push, branch-name, notify,\n' >&2 +printf ' local — comma-separate to combine)\n' >&2 +printf ' Disable ALL checks: export GIT_HOOKS_SKIP=all\n' >&2 +printf ' GIT_HOOKS_SKIP is a list of check names, not a boolean.\n' >&2 +printf ' An unrecognised value fails closed, and every skip is\n' >&2 +printf ' announced — if you saw no banner, nothing was skipped.\n' >&2 +printf ' The secret scan has no token; only the all-off value\n' >&2 +printf ' (all/1/true/yes/on) or --no-verify disables it.\n' >&2 +printf ' prepare-commit-msg ignores GIT_HOOKS_SKIP entirely.\n' >&2 printf ' Add repo logic: %s/local-\n' "$HOOKS_DIR" >&2 if [ -z "$RESQ_BIN" ]; then - printf 'warn resq backend not found. Hooks will soft-skip until you install it:\n' >&2 + # The installed pre-commit fails closed without resq: it refuses the commit + # rather than let an unscanned change through. Saying "soft-skip" here + # (true of hooks before resq-software/crates#206) told a first-time user + # their commits would pass while every one was refused. + printf 'warn resq backend not found. Until it is installed, pre-commit REFUSES every\n' >&2 + printf ' commit (no checks can run, so none are waived). Install it:\n' >&2 printf ' curl -fsSL https://raw.githubusercontent.com/resq-software/dev/main/scripts/install-resq.sh | sh\n' >&2 + printf ' To commit without it on purpose: GIT_HOOKS_SKIP=all, or git commit --no-verify.\n' >&2 exit 0 fi diff --git a/scripts/install-resq.sh b/scripts/install-resq.sh index ed88684..0c83ce3 100755 --- a/scripts/install-resq.sh +++ b/scripts/install-resq.sh @@ -73,7 +73,7 @@ TARGET="$(detect_target)" # crates revision. required.yml checks this commit exists and is an ancestor of # the crates default branch, so a typo or a rebased-away commit fails CI rather # than every user's install. -CRATES_COMMIT="d84cd9613da685ffb772a766673d6ba6a4acaf31" +CRATES_COMMIT="72e0ae4952624ccd5cf39adc632e15b3d91b86c9" cargo_install() { if ! command -v cargo >/dev/null 2>&1; then diff --git a/tests/hooks/git-hooks-skip.bats b/tests/hooks/git-hooks-skip.bats new file mode 100644 index 0000000..e763c10 --- /dev/null +++ b/tests/hooks/git-hooks-skip.bats @@ -0,0 +1,375 @@ +#!/usr/bin/env bats +# Copyright 2026 ResQ Systems, Inc. +# SPDX-License-Identifier: Apache-2.0 +# The GIT_HOOKS_SKIP contract. +# +# Regression suite for the incident of 2026-08: an operator ran +# `GIT_HOOKS_SKIP=audit` four times believing it skipped only the security +# audit. The guard was `[ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0`, so ANY +# non-empty value silently disabled the ENTIRE hook. Copyright, Large Files, +# Debug Stmts and — critically — the Secrets scan never ran, while the commit +# messages recorded that they had passed. The secret scan is this org's +# compensating control for not licensing GitHub Secret Protection, so a skip +# mechanism that looks selective but is total is a hole in that control. +# +# The contract these tests pin: +# +# GIT_HOOKS_SKIP is a token list (comma, colon or space separated, +# case-insensitive). The vocabulary is global across every canonical hook, +# so a value exported once for a shell session cannot mean different things +# in different hooks; a token owned by another hook is inert, not an error. +# +# all | 1 | true | yes | on disable this hook entirely (loud warning) +# 0 | false | no | off | none skip nothing (explicit no-op) +# audit / format / versioning pre-commit steps +# msg-format / wip-guard commit-msg checks +# force-push / branch-name pre-push guards +# notify post-merge, post-checkout lockfile notices +# local the repo-local local- override +# +# There is deliberately NO token for the secrets scan — it is reachable only +# via the all-off value, which announces itself loudly. +# +# Anything else FAILS CLOSED. On a gating hook (pre-commit, commit-msg) the +# hook refuses to run at all; on a non-gating hook it discards the whole +# variable and runs every check. Either way an unrecognized value never +# causes a check to be skipped. +# +# Every honoured skip is ANNOUNCED on stderr, naming what was skipped and +# what still ran. A skip is never silent. +# +# WHY ASSERTIONS USE THE "✅ " PREFIX +# Announcing a skip means naming the disabled checks, so "Secrets scan" and +# "Security audit" appear in the hook's own output whether or not they ran. +# Only the pass marker separates "this check ran" from "this check was named +# in the announcement". Tests therefore assert on "✅ Secrets", not "Secrets". +# The marker comes from the fake resq backend in helpers.bash, which mirrors +# crates/resq-cli/src/commands/pre_commit.rs :: run_plain(). +# +# WHICH HOOKS THIS DESCRIBES +# The templates come from the crates commit scripts/install-hooks.sh pins (see +# helpers.bash), i.e. the hooks a user is actually installed. The granular +# parser landed in resq-software/crates#206; any pin older than that carries the +# blanket guard and fails test 1 by construction. +# +# Known gap at the current pin: prepare-commit-msg has no GIT_HOOKS_SKIP +# handling at all — not even `all` or `local` stop its ticket prefix or its +# local-prepare-commit-msg dispatch. It gates nothing, so this suite does not +# pin that behaviour either way; the docs state the exception. + +load helpers + +setup() { + REPO="$(mktemp -d)" + init_repo_with_hooks "$REPO" + install_fake_resq + MSG="$REPO/.msg" +} + +teardown() { + remove_fake_resq + rm -rf "$REPO" +} + +write_msg() { printf '%s\n' "$1" > "$MSG"; } + +# ── The incident: a granular skip must stay granular ───────────────────────── + +@test "GIT_HOOKS_SKIP=audit still runs the secrets scan" { + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "GIT_HOOKS_SKIP=audit does not run the security audit" { + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP=audit still runs copyright, large-file and debug checks" { + # The four checks the incident's commit messages claimed had passed. + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Copyright"* ]] + [[ "$output" == *"✅ Large Files"* ]] + [[ "$output" == *"✅ Debug Stmts"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "GIT_HOOKS_SKIP=audit announces the skip rather than skipping silently" { + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"GIT_HOOKS_SKIP"* ]] + [[ "${output,,}" == *"skipped"* ]] +} + +@test "GIT_HOOKS_SKIP=audit still dispatches to local-pre-commit" { + cat > "$REPO/.git-hooks/local-pre-commit" <<'EOF' +#!/usr/bin/env bash +echo "LOCAL_PRE_COMMIT_RAN" +EOF + chmod +x "$REPO/.git-hooks/local-pre-commit" + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"LOCAL_PRE_COMMIT_RAN"* ]] +} + +# ── The other granular tokens ──────────────────────────────────────────────── + +@test "GIT_HOOKS_SKIP=format skips formatting but keeps the secrets scan" { + GIT_HOOKS_SKIP=format run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Format Rust"* ]] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP=versioning asks resq to skip versioning and keeps the secrets scan" { + # resq's plain-mode runner has no versioning step to observe, so assert on + # what the hook controls: the flag it passes, and what it announces. + GIT_HOOKS_SKIP=versioning run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"resq-args: "*"--skip-versioning"* ]] + [[ "$output" == *"SKIPPED: "*"Versioning"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "each pre-commit token passes exactly its own flag to resq" { + GIT_HOOKS_SKIP=audit run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"resq-args: "*"--skip-audit"* ]] + [[ "$output" != *"--skip-format"* ]] + [[ "$output" != *"--skip-versioning"* ]] +} + +@test "GIT_HOOKS_SKIP accepts a comma-separated list and keeps the secrets scan" { + GIT_HOOKS_SKIP=audit,format run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Audit"* ]] + [[ "$output" != *"✅ Format Rust"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "GIT_HOOKS_SKIP accepts a colon-separated list" { + GIT_HOOKS_SKIP=audit:format run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Audit"* ]] + [[ "$output" != *"✅ Format Rust"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "GIT_HOOKS_SKIP accepts a space-separated list" { + GIT_HOOKS_SKIP="audit format" run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Audit"* ]] + [[ "$output" != *"✅ Format Rust"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "GIT_HOOKS_SKIP=local skips local-pre-commit but not the canonical checks" { + cat > "$REPO/.git-hooks/local-pre-commit" <<'EOF' +#!/usr/bin/env bash +echo "LOCAL_PRE_COMMIT_RAN" +EOF + chmod +x "$REPO/.git-hooks/local-pre-commit" + GIT_HOOKS_SKIP=local run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"LOCAL_PRE_COMMIT_RAN"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +@test "a token owned by another hook is inert, and says nothing was disabled" { + GIT_HOOKS_SKIP=msg-format run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"disables nothing"* ]] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP=msg-format accepts a non-conventional subject" { + write_msg "wat: this is not a type" + GIT_HOOKS_SKIP=msg-format run run_hook "$REPO" commit-msg "$MSG" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED: "*"Conventional Commits"* ]] +} + +@test "GIT_HOOKS_SKIP=wip-guard allows a WIP commit on main" { + checkout_branch "$REPO" main + write_msg "WIP: still working" + GIT_HOOKS_SKIP=wip-guard,msg-format run run_hook "$REPO" commit-msg "$MSG" + [ "$status" -eq 0 ] + [[ "$output" == *"SKIPPED: "*"WIP guard"* ]] +} + +@test "GIT_HOOKS_SKIP=branch-name allows a badly named branch push" { + LOCAL=$(git -C "$REPO" rev-parse HEAD) + LINE="refs/heads/nope/bad-prefix $LOCAL refs/heads/nope/bad-prefix 0000000000000000000000000000000000000000" + GIT_HOOKS_SKIP=branch-name run bash -c "cd '$REPO' && printf '%s\n' '$LINE' | bash .git-hooks/pre-push origin git@example" + [ "$status" -eq 0 ] + [[ "$output" != *"does not follow naming convention"* ]] + [[ "$output" == *"SKIPPED: "*"Branch naming"* ]] +} + +@test "GIT_HOOKS_SKIP=force-push allows a force push to main, and announces it" { + checkout_branch "$REPO" main + LOCAL=$(git -C "$REPO" rev-parse HEAD) + LINE="refs/heads/main $LOCAL refs/heads/main 0000000000000000000000000000000000000001" + GIT_HOOKS_SKIP=force-push run bash -c "cd '$REPO' && printf '%s\n' '$LINE' | bash .git-hooks/pre-push origin git@example" + [ "$status" -eq 0 ] + [[ "$output" != *"Force push to main is not allowed"* ]] + [[ "$output" == *"SKIPPED: "*"orce"* ]] +} + +@test "GIT_HOOKS_SKIP=notify silences the post-checkout lock-file notice" { + PREV=$(git -C "$REPO" rev-parse HEAD) + printf 'lock\n' > "$REPO/Cargo.lock" + git -C "$REPO" add Cargo.lock + commit_no_hooks "$REPO" "feat: lock" + NEW=$(git -C "$REPO" rev-parse HEAD) + GIT_HOOKS_SKIP=notify run run_hook "$REPO" post-checkout "$PREV" "$NEW" 1 + [ "$status" -eq 0 ] + [[ "$output" != *"Cargo.lock changed"* ]] +} + +@test "GIT_HOOKS_SKIP=0 is an explicit no-op, not a skip" { + # Under the old `[ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0` guard the literal + # string "0" was non-empty, so it disabled every check — the exact + # opposite of what anyone writing it would intend. + GIT_HOOKS_SKIP=0 run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP=false is an explicit no-op, not a skip" { + GIT_HOOKS_SKIP=false run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP is case-insensitive" { + GIT_HOOKS_SKIP=AUDIT run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Audit"* ]] + [[ "$output" == *"✅ Secrets"* ]] +} + +# ── Fail closed on anything unrecognized ───────────────────────────────────── + +@test "GIT_HOOKS_SKIP=typo fails closed instead of skipping everything" { + GIT_HOOKS_SKIP=typo run run_hook_with_resq "$REPO" pre-commit + [ "$status" -ne 0 ] + [[ "$output" == *"typo"* ]] +} + +@test "GIT_HOOKS_SKIP=typo names the accepted values" { + GIT_HOOKS_SKIP=typo run run_hook_with_resq "$REPO" pre-commit + [ "$status" -ne 0 ] + [[ "$output" == *"audit"* ]] + [[ "$output" == *"format"* ]] + [[ "$output" == *"versioning"* ]] +} + +@test "a typo'd token inside an otherwise valid list still fails closed" { + # The dangerous shape: one good token lends the whole list credibility. + GIT_HOOKS_SKIP=audit,tpyo run run_hook_with_resq "$REPO" pre-commit + [ "$status" -ne 0 ] + [[ "$output" == *"tpyo"* ]] +} + +@test "the secrets scan has no token of its own" { + # Asking for it by name is refused, and says why. + GIT_HOOKS_SKIP=secrets run run_hook_with_resq "$REPO" pre-commit + [ "$status" -ne 0 ] + [[ "${output,,}" == *"secret"* ]] + [[ "$output" != *"✅ Copyright"* ]] +} + +@test "an unrecognized value never skips a commit-msg check" { + # Holds whether the hook hard-fails on the bad token or discards the + # variable and runs everything: either way the bad message is rejected. + write_msg "wat: this is not a type" + GIT_HOOKS_SKIP=typo run run_hook "$REPO" commit-msg "$MSG" + [ "$status" -ne 0 ] + [[ "$output" == *"typo"* ]] +} + +# ── Granular tokens must not disarm unrelated hooks ────────────────────────── + +@test "GIT_HOOKS_SKIP=audit still rejects a bad commit message" { + write_msg "wat: this is not a type" + GIT_HOOKS_SKIP=audit run run_hook "$REPO" commit-msg "$MSG" + [ "$status" -ne 0 ] + [[ "$output" == *"Invalid commit message"* ]] +} + +@test "GIT_HOOKS_SKIP=audit still enforces the branch naming convention" { + # The rule reads the ref being pushed to (stdin), not the checked-out + # branch, so the push line has to name the bad branch. + LOCAL=$(git -C "$REPO" rev-parse HEAD) + LINE="refs/heads/nope/bad-prefix $LOCAL refs/heads/nope/bad-prefix 0000000000000000000000000000000000000000" + GIT_HOOKS_SKIP=audit run bash -c "cd '$REPO' && printf '%s\n' '$LINE' | bash .git-hooks/pre-push origin git@example" + [ "$status" -ne 0 ] + [[ "$output" == *"does not follow naming convention"* ]] +} + +@test "GIT_HOOKS_SKIP=audit still blocks a force push to main" { + checkout_branch "$REPO" main + LOCAL=$(git -C "$REPO" rev-parse HEAD) + REMOTE="0000000000000000000000000000000000000001" + LINE="refs/heads/main $LOCAL refs/heads/main $REMOTE" + GIT_HOOKS_SKIP=audit run bash -c "cd '$REPO' && printf '%s\n' '$LINE' | bash .git-hooks/pre-push origin git@example" + [ "$status" -ne 0 ] + [[ "$output" == *"Force push"* ]] +} + +# ── The blunt instrument still works, but says so ──────────────────────────── + +@test "GIT_HOOKS_SKIP=1 still skips every check" { + GIT_HOOKS_SKIP=1 run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" != *"✅ Copyright"* ]] + [[ "$output" != *"✅ Secrets"* ]] + [[ "$output" != *"✅ Audit"* ]] +} + +@test "GIT_HOOKS_SKIP=1 warns that it disabled the hooks" { + GIT_HOOKS_SKIP=1 run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"GIT_HOOKS_SKIP=1"* ]] + [[ "${output,,}" == *"disabled"* ]] +} + +@test "GIT_HOOKS_SKIP=1 names the secret scan as one of the disabled controls" { + # The incident's real cost: commit messages claimed a scan that never ran. + # The operator must be told, by name, that the scanner is off. + GIT_HOOKS_SKIP=1 run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "${output,,}" == *"secret"* ]] +} + +@test "GIT_HOOKS_SKIP=1 warns from commit-msg as well" { + write_msg "garbage that would normally fail" + GIT_HOOKS_SKIP=1 run run_hook "$REPO" commit-msg "$MSG" + [ "$status" -eq 0 ] + [[ "$output" == *"GIT_HOOKS_SKIP=1"* ]] +} + +# ── Unset / empty must remain a full, quiet run ────────────────────────────── + +@test "unset GIT_HOOKS_SKIP runs every check" { + run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] +} + +@test "empty GIT_HOOKS_SKIP runs every check and announces nothing" { + GIT_HOOKS_SKIP= run run_hook_with_resq "$REPO" pre-commit + [ "$status" -eq 0 ] + [[ "$output" == *"✅ Secrets"* ]] + [[ "$output" == *"✅ Audit"* ]] + [[ "$output" != *"GIT_HOOKS_SKIP"* ]] +} diff --git a/tests/hooks/helpers.bash b/tests/hooks/helpers.bash index 7656087..5f47858 100644 --- a/tests/hooks/helpers.bash +++ b/tests/hooks/helpers.bash @@ -11,15 +11,75 @@ # Override the source with RESQ_HOOK_SRC_DIR=/path/to/local/templates to # test a local change before pushing it to crates. -HOOK_SRC_CACHE="${RESQ_HOOK_SRC_DIR:-/tmp/resq-canonical-hooks}" -HOOK_RAW_BASE="${RESQ_HOOK_RAW_BASE:-https://raw.githubusercontent.com/resq-software/crates/master/crates/resq-cli/templates/git-hooks}" +# +# By default the suite tests the crates commit scripts/install-hooks.sh pins, +# because that is what a user without resq on PATH gets installed. Testing +# `master` instead let this suite pass for behaviour the installer did not ship +# (the granular GIT_HOOKS_SKIP fix sat on master for weeks behind a pin that +# predated it). Set RESQ_HOOK_REF=master to test upstream ahead of a pin bump; +# hooks-tests.yml does that weekly. +_hooks_helpers_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK_REF="${RESQ_HOOK_REF:-$(sed -n 's/^ *CRATES_COMMIT="\(.*\)"$/\1/p' "$_hooks_helpers_dir/../../scripts/install-hooks.sh")}" +[ -n "$HOOK_REF" ] || { echo "helpers.bash: could not read CRATES_COMMIT from scripts/install-hooks.sh" >&2; exit 1; } +HOOK_RAW_BASE="${RESQ_HOOK_RAW_BASE:-https://raw.githubusercontent.com/resq-software/crates/$HOOK_REF/crates/resq-cli/templates/git-hooks}" +HOOK_NAMES="pre-commit commit-msg prepare-commit-msg pre-push post-checkout post-merge" +_hooks_pinned_commit="$(sed -n 's/^ *CRATES_COMMIT="\(.*\)"$/\1/p' "$_hooks_helpers_dir/../../scripts/install-hooks.sh")" + +# Where the templates come from, most specific first: +# RESQ_HOOK_SRC_DIR a local template dir (e.g. an unpushed crates change), +# used as-is: never fetched into, never digest-checked. +# the pinned commit cached across runs under /tmp, and every file checked +# against the digest install-hooks.sh enforces, so a stale, +# half-written or tampered cache is refetched, not trusted. +# anything else a moving branch such as `master`, or a custom +# RESQ_HOOK_RAW_BASE — fetched fresh into this run's own +# tmpdir, because a cache keyed on it could go stale. +HOOK_VERIFY=0 +if [ -n "${RESQ_HOOK_SRC_DIR:-}" ]; then + HOOK_SRC_CACHE="$RESQ_HOOK_SRC_DIR" +elif [ "$HOOK_REF" = "$_hooks_pinned_commit" ] && [ -z "${RESQ_HOOK_RAW_BASE:-}" ]; then + HOOK_SRC_CACHE="/tmp/resq-canonical-hooks-$HOOK_REF" + HOOK_VERIFY=1 +else + # BATS_RUN_TMPDIR is unique per bats invocation and shared by its tests. + HOOK_SRC_CACHE="${BATS_RUN_TMPDIR:-$(mktemp -d)}/resq-canonical-hooks" +fi + +_hook_sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | cut -d' ' -f1 + else shasum -a 256 < "$1" | cut -d' ' -f1; fi +} + +# The digest install-hooks.sh pins for hook $1. +_pinned_hook_digest() { + sed -n "s/^ *$1) *echo \"\([0-9a-f]\{64\}\)\" ;;\$/\1/p" "$_hooks_helpers_dir/../../scripts/install-hooks.sh" +} + +_hook_cache_valid() { + local h + for h in $HOOK_NAMES; do + [ -s "$HOOK_SRC_CACHE/$h" ] || return 1 + [ "$HOOK_VERIFY" = 1 ] || continue + [ "$(_hook_sha256 "$HOOK_SRC_CACHE/$h")" = "$(_pinned_hook_digest "$h")" ] || return 1 + done +} _ensure_hook_cache() { - [ -d "$HOOK_SRC_CACHE" ] && [ -e "$HOOK_SRC_CACHE/pre-commit" ] && return 0 + _hook_cache_valid && return 0 + # Never fetch into a user-supplied template dir: it may hold unpushed edits + # to the hooks that ARE present, and the fetch would overwrite them. + if [ -n "${RESQ_HOOK_SRC_DIR:-}" ]; then + echo "helpers.bash: RESQ_HOOK_SRC_DIR=$RESQ_HOOK_SRC_DIR must contain all of: $HOOK_NAMES" >&2 + return 1 + fi mkdir -p "$HOOK_SRC_CACHE" - for h in pre-commit commit-msg prepare-commit-msg pre-push post-checkout post-merge; do - curl -fsSL "$HOOK_RAW_BASE/$h" -o "$HOOK_SRC_CACHE/$h" + for h in $HOOK_NAMES; do + curl -fsSL "$HOOK_RAW_BASE/$h" -o "$HOOK_SRC_CACHE/$h" || return 1 done + _hook_cache_valid || { + echo "helpers.bash: hooks fetched from $HOOK_RAW_BASE do not match the digests pinned in scripts/install-hooks.sh" >&2 + return 1 + } } # Initialize a fresh git repo in $1 with canonical hooks installed. @@ -58,3 +118,64 @@ commit_no_hooks() { shift 2 git -C "$dir" -c "core.hooksPath=" commit --allow-empty -q -m "$msg" "$@" } + +# ── Fake `resq` backend ────────────────────────────────────────────────────── +# The canonical pre-commit hook delegates its actual checks to `resq +# pre-commit`. To assert *which checks the hook asks for* without depending on +# a real resq install (CI has none), install a stub on PATH that reproduces the +# plain, non-TUI step output a developer actually sees. +# +# Source of truth for these strings: resq-software/crates +# crates/resq-cli/src/commands/pre_commit.rs :: run_plain() +# Keep the step names and the --skip-* branching in sync with that function. +# run_plain has no versioning step and does not take skip_versioning, so the +# stub prints none either; tests see --skip-versioning through the +# "resq-args:" line, which records exactly what the hook passed. +# +# The "✅ " prefix matters. A hook that announces a skip has to name the checks +# it disabled ("SKIPPED: ... Secrets scan"), so a bare step name appears in the +# output whether the step ran or not. Only the pass marker distinguishes "this +# check actually ran" from "this check was mentioned in the announcement", so +# tests assert on "✅ Secrets", never on "Secrets". +# +# Sets two variables for the caller: +# FAKE_BIN dir to put first on PATH (contains the stub) +# FAKE_HOME clean HOME, so the hook's $HOME/.cargo/bin/resq fallback cannot +# reach a real binary on a developer machine +install_fake_resq() { + FAKE_BIN="$(mktemp -d)" + FAKE_HOME="$(mktemp -d)" + cat > "$FAKE_BIN/resq" <<'STUB' +#!/usr/bin/env bash +[ "${1:-}" = "pre-commit" ] || exit 0 +echo "resq-args: $*" +skip_audit=0 +skip_format=0 +for a in "$@"; do + case "$a" in + --skip-audit) skip_audit=1 ;; + --skip-format) skip_format=1 ;; + esac +done +echo " ✅ Copyright" +echo " ✅ Large Files" +echo " ✅ Debug Stmts" +echo " ✅ Secrets" +[ "$skip_audit" -eq 1 ] || echo " ✅ Audit" +[ "$skip_format" -eq 1 ] || echo " ✅ Format Rust" +STUB + chmod +x "$FAKE_BIN/resq" +} + +remove_fake_resq() { + rm -rf "${FAKE_BIN:-/nonexistent}" "${FAKE_HOME:-/nonexistent}" +} + +# Run a hook with the fake resq backend resolvable and nothing else. +# run_hook_with_resq [args...] +run_hook_with_resq() { + local dir="$1" hook="$2" + shift 2 + (cd "$dir" && PATH="$FAKE_BIN:/usr/bin:/bin" HOME="$FAKE_HOME" \ + bash ".git-hooks/$hook" "$@") +} diff --git a/tests/hooks/install-hooks.bats b/tests/hooks/install-hooks.bats new file mode 100644 index 0000000..1c47e31 --- /dev/null +++ b/tests/hooks/install-hooks.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats +# Copyright 2026 ResQ Systems, Inc. +# SPDX-License-Identifier: Apache-2.0 +# Which hooks scripts/install-hooks.sh installs, by resq version. +# +# Path 1 (`resq hooks install`) writes the templates embedded in the binary, +# so an old resq installs old hooks — before resq-cli 0.4.3 that meant the +# blanket GIT_HOOKS_SKIP guard, under which GIT_HOOKS_SKIP=audit also disabled +# the secret scan. Below the floor, or when the version can't be read, the +# installer must take path 2: the pinned, digest-verified templates. + +load helpers + +INSTALLER="$BATS_TEST_DIRNAME/../../scripts/install-hooks.sh" + +setup() { + REPO="$(mktemp -d)" + git -C "$REPO" init -q + FAKE_BIN="$(mktemp -d)" + FAKE_HOME="$(mktemp -d)" + CALLS="$FAKE_BIN/calls" + : > "$CALLS" + cat > "$FAKE_BIN/resq" <<'STUB' +#!/usr/bin/env bash +echo "$*" >> "$CALLS" +[ "${1:-}" = "--version" ] && echo "$FAKE_RESQ_VERSION_LINE" +exit 0 +STUB + chmod +x "$FAKE_BIN/resq" +} + +teardown() { + rm -rf "$REPO" "$FAKE_BIN" "$FAKE_HOME" +} + +# run_installer +run_installer() { + run env PATH="$FAKE_BIN:/usr/bin:/bin" HOME="$FAKE_HOME" CALLS="$CALLS" \ + FAKE_RESQ_VERSION_LINE="$1" RESQ_SKIP_LOCAL_SCAFFOLD=1 \ + sh "$INSTALLER" "$REPO" +} + +used_resq_templates() { grep -qxE 'hooks install|dev install-hooks' "$CALLS"; } + +installed_pinned_hooks() { + local h + for h in $HOOK_NAMES; do + [ "$(_hook_sha256 "$REPO/.git-hooks/$h")" = "$(_pinned_hook_digest "$h")" ] || return 1 + done +} + +@test "resq below the floor installs the pinned hooks, not its embedded ones" { + run_installer "resq 0.1.0" + [ "$status" -eq 0 ] + ! used_resq_templates + installed_pinned_hooks + [[ "$output" == *"0.1.0"* ]] + [[ "$output" == *"predate the granular"* ]] +} + +@test "resq one patch below the floor still falls back" { + run_installer "resq 0.4.2" + [ "$status" -eq 0 ] + ! used_resq_templates + installed_pinned_hooks +} + +@test "an unreadable resq version falls back rather than trusting the binary" { + run_installer "something unexpected" + [ "$status" -eq 0 ] + ! used_resq_templates + installed_pinned_hooks + [[ "$output" == *""* ]] +} + +@test "resq at the floor installs its own embedded hooks" { + run_installer "resq 0.4.3" + [ "$status" -eq 0 ] + used_resq_templates + [[ "$output" != *"predate the granular"* ]] +} + +@test "versions compare numerically, not as strings" { + # "0.10.0" < "0.4.3" as a string; it is newer as a version. + run_installer "resq 0.10.0" + [ "$status" -eq 0 ] + used_resq_templates +} diff --git a/tests/hooks/post-checkout.bats b/tests/hooks/post-checkout.bats index afb5687..e32cbea 100644 --- a/tests/hooks/post-checkout.bats +++ b/tests/hooks/post-checkout.bats @@ -27,6 +27,9 @@ teardown() { @test "post-checkout silent when no lockfile changed" { PREV=$(git -C "$REPO" rev-parse HEAD) + # Must not fire the installed hooks: on a machine that has a real `resq` + # this setup commit would run the full pre-commit suite (and fail on the + # security audit). Passes in CI only because CI has no resq binary. commit_no_hooks "$REPO" "feat: nothing" NEW=$(git -C "$REPO" rev-parse HEAD) run run_hook "$REPO" post-checkout "$PREV" "$NEW" 1 diff --git a/tests/hooks/pre-commit.bats b/tests/hooks/pre-commit.bats index 653b21e..be10319 100644 --- a/tests/hooks/pre-commit.bats +++ b/tests/hooks/pre-commit.bats @@ -1,7 +1,8 @@ #!/usr/bin/env bats # Copyright 2026 ResQ Systems, Inc. # SPDX-License-Identifier: Apache-2.0 -# Soft-skip + local dispatch behavior in the canonical pre-commit hook. +# Fail-closed-without-resq + local dispatch behavior in the canonical +# pre-commit hook. # (The full `resq pre-commit` checks are exercised by resq-cli's own tests; # here we verify wiring only.)