Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/workflows/hooks-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
60 changes: 56 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,68 @@ 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:

```sh
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-<hook>` 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 <hook> — 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.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tokens>` — 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
Expand Down Expand Up @@ -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-<hook-name>` 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 <hook> — 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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.5
0.4.6
4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
57 changes: 48 additions & 9 deletions scripts/install-hooks.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 { '<unreadable>' }
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
Expand All @@ -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
Expand Down Expand Up @@ -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-<hook-name>"

# `return`, not `exit`. install.ps1 runs this file as a ScriptBlock
Expand All @@ -175,9 +211,12 @@ Write-Host " Add repo logic: $hooksDir/local-<hook-name>"
# 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
}

Expand Down
Loading
Loading