diff --git a/.git-hooks/README.md b/.git-hooks/README.md index 45af17d..47ddea2 100644 --- a/.git-hooks/README.md +++ b/.git-hooks/README.md @@ -1,3 +1,19 @@ + + # Git Hooks — ResQ Programs This directory contains the project's git hooks. They enforce code quality, security, and workflow conventions for the ResQ Solana/Anchor programs (Rust/Bun). @@ -16,14 +32,29 @@ The setup script sets `core.hooksPath` to `.git-hooks` and makes all hooks execu ## Active Hooks +These are the canonical ResQ hooks, owned by +[`resq-software/crates`](https://github.com/resq-software/crates/tree/master/crates/resq-cli/templates/git-hooks) +and installed by `resq hooks update`. They are canonical shims: each keeps the +validation and reporting specific to its own hook, hands the heavier checks to +the `resq` binary where there are any, and then runs an executable repo-owned +`local-*` override. Editing them here only produces drift. + | Hook | Purpose | |------|---------| -| `pre-commit` | Large file guard (1 MB limit), secrets scan (gitleaks / grep fallback), `cargo fmt --all -- --check` on staged `.rs` files | +| `pre-commit` | Delegates to `resq pre-commit` — copyright headers, large-file guard, secret scan, dependency audit, per-language formatting | | `commit-msg` | Conventional Commits format validation; blocks `fixup!`/`squash!`/WIP on `main` | | `prepare-commit-msg` | Prepends ticket reference (e.g., `[PROJ-123]`) extracted from branch name | -| `pre-push` | Force-push guard on `main`, branch naming convention, `cargo check --workspace` on changed Rust/Anchor files | -| `post-checkout` | Notifies on `Cargo.lock` changes; auto `bun install` when `bun.lockb` changes between branches | -| `post-merge` | Notifies on `Cargo.lock` changes; auto `bun install` when `bun.lockb` changes after a merge | +| `pre-push` | Force-push guard and branch-naming rule, both applied to the ref being **pushed to**; then runs `local-pre-push` | +| `post-checkout` | **Reports** changed `Cargo.lock` / `bun.lock` / `uv.lock` / `flake.lock` and the command to run; then runs `local-post-checkout` | +| `post-merge` | **Reports** the same lockfile changes after a merge; then runs `local-post-merge` | + +| Local hook | Purpose | +|------|---------| +| `local-pre-push` | `cargo check --workspace` when Rust/Anchor files changed. Skip with `SKIP_CARGO_CHECK=1` | + +The lockfile hooks report rather than installing for you. A hook that mutates +the working tree during a checkout is a surprise, and the dependency state you +want after switching branches is not always the one the lockfile names. ## Bypassing Hooks diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg index 0fc26fd..67d1289 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -1,62 +1,55 @@ #!/usr/bin/env bash -set -euo pipefail -# -# Copyright 2026 ResQ -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# commit-msg -# -# Validates the commit message format. -# Ensures the subject line follows the Conventional Commits specification. -# -# Usage: -# .git-hooks/commit-msg COMMIT_MSG_FILE -# -# Arguments: -# $1 - Path to the file containing the commit message. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Exit codes: -# 0 Commit message is valid. -# 1 Commit message is invalid. +# Canonical ResQ commit-msg shim — source: resq-software/dev. +# Enforces Conventional Commits; blocks fixup/squash/WIP on main/master. + +set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# INPUT_FILE stores the path to the commit message file. -INPUT_FILE=${1:-} -# PATTERN is the regular expression for a valid Conventional Commit message. +INPUT_FILE="${1:-}" +# git always passes the message file, but the `${1:-}` default means a hook run +# by hand, or by a tool that forgets the argument, reaches `head -1 ""` instead — +# and `set -e` turns that into a bare tool error naming neither the hook nor the +# cause. Fail here, where the message can say what was expected. +if [ -z "$INPUT_FILE" ] || [ ! -f "$INPUT_FILE" ]; then + echo "❌ commit-msg: expected a commit-message file as \$1, got '$INPUT_FILE'." >&2 + exit 1 +fi + PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+$" -# Validate only the first line (subject). Strip an optional [TICKET-123] prefix -# inserted by prepare-commit-msg so the two hooks don't conflict. FIRST_LINE=$(head -1 "$INPUT_FILE") -SUBJECT=$(echo "$FIRST_LINE" | sed 's/^\[[A-Z][A-Z]*-[0-9]*\] //') +# Ticket-prefix regex matches what prepare-commit-msg prepends ({2,} chars). +SUBJECT=$(sed -E 's/^\[[A-Z]{2,}-[0-9]+\][[:space:]]*//' <<<"$FIRST_LINE") + +# WIP / fixup! / squash! guard runs *before* the format check so users get +# a branch-specific error message on main/master instead of the generic +# "Invalid commit message format". +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +case "$BRANCH" in + main|master) + if grep -qiE "^(\[[A-Z]{2,}-[0-9]+\] )?(fixup!|squash!|wip[: ])" <<<"$FIRST_LINE"; then + echo "Error: fixup!/squash!/WIP commits are not allowed on $BRANCH." + echo "Create a feature branch instead." + exit 1 + fi + ;; +esac -if ! echo "$SUBJECT" | grep -qE "$PATTERN"; then +if ! grep -qE "$PATTERN" <<<"$SUBJECT"; then echo "Error: Invalid commit message format." - echo "Expected format: type(scope): subject" + echo "Expected: type(scope)(!): subject" echo "Examples:" echo " feat(core): add new feature" + echo " feat!: remove deprecated API (breaking change marker)" echo " fix(ui): fix button color" exit 1 fi -# Block fixup!/WIP commits on main -BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -if [ "$BRANCH" = "main" ]; then - if echo "$FIRST_LINE" | grep -qiE "^(\[[A-Z]+-[0-9]+\] )?(fixup!|squash!|wip[: ])"; then - echo "Error: fixup!/squash!/WIP commits are not allowed on main." - echo "Create a feature branch instead." - exit 1 - fi +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-commit-msg" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" fi diff --git a/.git-hooks/local-pre-push b/.git-hooks/local-pre-push new file mode 100755 index 0000000..8845489 --- /dev/null +++ b/.git-hooks/local-pre-push @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# Copyright 2026 ResQ Systems, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Repo-specific pre-push — runs after the canonical ResQ pre-push hook, which +# execs this file when it exists. +# +# The canonical hook is language-agnostic on purpose: it guards branch naming +# and force-pushes to main, and delegates anything needing a toolchain to here. +# Rust/Anchor validation therefore lives in this file rather than in the shared +# template, which is also what `.git-hooks/README.md` promises for `pre-push`. +# +# Skip with SKIP_CARGO_CHECK=1 for a push that cannot affect compilation. +set -euo pipefail + +[ "${SKIP_CARGO_CHECK:-0}" = "1" ] && { echo " Rust: skipped (SKIP_CARGO_CHECK=1)"; exit 0; } + +if ! command -v cargo >/dev/null 2>&1; then + echo " Rust: cargo not found, skipping workspace check" + exit 0 +fi + +ZERO_SHA="0000000000000000000000000000000000000000" +HEAD_SHA=$(git rev-parse HEAD 2>/dev/null || echo "") + +# The canonical hook forwards git's ref records, so read them rather than +# assuming the push is of the checked-out branch: `git push origin feat/rust` +# from elsewhere, or `git push --all`, sends refs this hook would never see by +# looking at HEAD alone. +# +# Clippy compiles the *working tree*, so it can only ever speak for what is +# checked out. Rather than pretend otherwise, scope conservatively: lint +# whenever a pushed ref is something other than HEAD, and use the cheap +# file-diff skip only when the push is exactly the branch in hand. +run_check=0 +scoped=1 +if [ ! -t 0 ]; then + while read -r _local_ref local_sha _remote_ref _remote_sha; do + [ -z "$local_sha" ] && continue + [ "$local_sha" = "$ZERO_SHA" ] && continue # deletion: nothing to compile + if [ "$local_sha" != "$HEAD_SHA" ]; then + scoped=0 + run_check=1 + fi + done +fi + +if [ "$scoped" = "1" ]; then + REMOTE="${1:-origin}" + # Compare against the tracking branch when there is one; a brand-new branch + # has no upstream yet, so fall back to the remote's main. + REMOTE_BRANCH=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || echo "$REMOTE/main") + if CHANGED_RS=$(git diff --name-only "$REMOTE_BRANCH"...HEAD -- '*.rs' 'Cargo.toml' 'Cargo.lock' 2>/dev/null); then + [ -n "$CHANGED_RS" ] && run_check=1 + else + # The range would not resolve — shallow clone, missing upstream. Fail + # toward running the check rather than silently skipping it. + run_check=1 + fi +fi + +if [ "$run_check" = "1" ]; then + # Clippy, not `cargo check`: AGENTS.md names + # `cargo clippy --workspace -- -D warnings` as this repo's lint gate, and + # `cargo check` passes happily on the warnings clippy exists to reject. + echo " Rust: cargo clippy --workspace -- -D warnings" + if ! cargo clippy --workspace --quiet -- -D warnings; then + echo "❌ cargo clippy failed. Fix the warnings before pushing." + echo " Override with: git push --no-verify" + exit 1 + fi + echo "✅ Rust workspace OK" +fi diff --git a/.git-hooks/post-checkout b/.git-hooks/post-checkout index 1ce8956..593a3c6 100755 --- a/.git-hooks/post-checkout +++ b/.git-hooks/post-checkout @@ -1,71 +1,35 @@ #!/usr/bin/env bash -set -euo pipefail -# -# Copyright 2026 ResQ -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# post-checkout -# -# Automatically installs dependencies when lock files change during branch checkout. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Usage: -# .git-hooks/post-checkout PREV_HEAD NEW_HEAD IS_BRANCH_CHECKOUT -# -# Arguments: -# $1 - Previous HEAD commit hash. -# $2 - New HEAD commit hash. -# $3 - Flag indicating if it's a branch checkout (1) or file checkout (0). -# -# Exit codes: -# 0 Always. +# Canonical ResQ post-checkout shim — source: resq-software/dev. +# Notifies when lock files change so devs know to resync dependencies. + +set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# PREV_HEAD stores the commit hash before checkout. PREV_HEAD="${1:-}" -# NEW_HEAD stores the commit hash after checkout. NEW_HEAD="${2:-}" -# IS_BRANCH_CHECKOUT is "1" if a branch was checked out, "0" otherwise. IS_BRANCH_CHECKOUT="${3:-}" -# Only run on branch checkouts, not file checkouts -if [ "$IS_BRANCH_CHECKOUT" != "1" ]; then - exit 0 -fi +if [ "$IS_BRANCH_CHECKOUT" = "1" ] && [ "$PREV_HEAD" != "$NEW_HEAD" ]; then + CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" 2>/dev/null || true) -# Skip if both heads are the same (no actual branch change) -if [ "$PREV_HEAD" = "$NEW_HEAD" ]; then - exit 0 + # Matched by basename rather than anchored to the repository root: a monorepo + # keeps lockfiles in nested workspaces (`libs/dotnet/flake.lock`, + # `programs/Cargo.lock`), and a root-anchored pattern ignores them all. + grep -qE "(^|/)Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed — run: cargo build" + # `\?` is a GNU BRE extension; BSD grep on macOS reads it as a literal `?`, + # so the optional-`b` form needs ERE to match on every developer's box. + grep -qE "(^|/)bun\.lockb?$" <<<"$CHANGED" && echo "📦 bun.lock changed — run: bun install" + grep -qE "(^|/)uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed — run: uv sync" + grep -qE "(^|/)flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed — exit and re-enter: nix develop" fi -# CHANGED stores the list of files that differ between the two heads. -CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" 2>/dev/null) - -# Check if Cargo.lock changed -if echo "$CHANGED" | grep -q "^Cargo\.lock$"; then - echo "📦 Cargo.lock changed — dependencies will update on next build" +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-post-checkout" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" fi -# Check if bun.lockb changed -if echo "$CHANGED" | grep -q "^bun\.lockb$"; then - if [ -n "${SKIP_BUN_INSTALL:-}" ]; then - echo "📦 bun.lockb changed — skipping install (SKIP_BUN_INSTALL set)" - elif command -v bun >/dev/null 2>&1; then - echo "📦 bun.lockb changed — running bun install..." - bun install --frozen-lockfile 2>/dev/null || bun install - echo "✅ JS dependencies updated" - else - echo "⚠️ bun not found — run 'bun install' manually" - fi -fi +exit 0 diff --git a/.git-hooks/post-merge b/.git-hooks/post-merge index 8ae642d..f09d1bf 100755 --- a/.git-hooks/post-merge +++ b/.git-hooks/post-merge @@ -1,49 +1,37 @@ #!/usr/bin/env bash -set -euo pipefail -# -# Copyright 2026 ResQ -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# post-merge -# -# Automatically installs dependencies when lock files change after a merge. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Usage: -# .git-hooks/post-merge -# -# Exit codes: -# 0 Always. +# Canonical ResQ post-merge shim — source: resq-software/dev. +# Notifies when lock files change after a merge. -[ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 +set -euo pipefail -# CHANGED_FILES stores the list of files changed in the merge. -CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null) +[ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# Check if Cargo.lock changed after merge -if echo "$CHANGED_FILES" | grep -q "^Cargo\.lock$"; then - echo "📦 Cargo.lock changed after merge — dependencies will update on next build" +# git passes 1 for a squash merge. `--squash` stages the result without +# committing, so HEAD has not moved and a tree-to-tree diff sees nothing — +# compare the working tree against ORIG_HEAD instead, which is where the merged +# changes actually are. +if [ "${1:-0}" = "1" ]; then + CHANGED=$(git diff --name-only ORIG_HEAD 2>/dev/null || true) +else + CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null || true) fi -# Check if bun.lockb changed after merge -if echo "$CHANGED_FILES" | grep -q "^bun\.lockb$"; then - if [ -n "${SKIP_BUN_INSTALL:-}" ]; then - echo "📦 bun.lockb changed after merge — skipping install (SKIP_BUN_INSTALL set)" - elif command -v bun >/dev/null 2>&1; then - echo "📦 bun.lockb changed after merge — running bun install..." - bun install --frozen-lockfile 2>/dev/null || bun install - echo "✅ JS dependencies updated" - else - echo "⚠️ bun not found — run 'bun install' manually" - fi +# Matched by basename rather than anchored to the repository root: a monorepo +# keeps lockfiles in nested workspaces (`libs/dotnet/flake.lock`, +# `programs/Cargo.lock`), and a root-anchored pattern silently ignores them all. +grep -qE "(^|/)Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed after merge — run: cargo build" +# `\?` is a GNU BRE extension; BSD grep on macOS reads it as a literal `?`, so +# the optional-`b` form needs ERE to match `bun.lock` on every developer's box. +grep -qE "(^|/)bun\.lockb?$" <<<"$CHANGED" && echo "📦 bun.lock changed after merge — run: bun install" +grep -qE "(^|/)uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed after merge — run: uv sync" +grep -qE "(^|/)flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed after merge — exit and re-enter: nix develop" + +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-post-merge" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" fi + +exit 0 diff --git a/.git-hooks/pre-commit b/.git-hooks/pre-commit index 035acd5..dd24586 100755 --- a/.git-hooks/pre-commit +++ b/.git-hooks/pre-commit @@ -1,7 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail -# -# Copyright 2026 ResQ +# Copyright 2026 ResQ Software # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -9,146 +7,36 @@ set -euo pipefail # # http://www.apache.org/licenses/LICENSE-2.0 # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# pre-commit -# -# Runs checks before allowing a commit. -# Delegates to resq CLI if available; otherwise runs inline checks. -# Includes large file guard, secret scanning, OSV audit, and Rust formatting. -# -# Usage: -# .git-hooks/pre-commit -# -# Requirements: -# git, cargo/rustfmt (optional), osv-scanner (optional), gitleaks (optional), nix (optional). -# -# Exit codes: -# 0 All checks passed. -# 1 Blocking check failed. - -# ── Environment Setup ───────────────────────────────────────────────────────── - -# HOOK_PATH stores the absolute path to this script. -if [ ${#BASH_SOURCE[@]} -gt 0 ] && [ -n "${BASH_SOURCE[0]:-}" ]; then - HOOK_PATH="${BASH_SOURCE[0]}" -else - HOOK_PATH="$0" -fi +# Canonical ResQ pre-commit shim — source: resq-software/dev. +# Delegates all logic to `resq pre-commit`; runs `.git-hooks/local-pre-commit` +# as a repo-specific escape hatch if present and executable. -# Resolve symlinks -HOOK_PATH=$(readlink -f "$HOOK_PATH") -# SCRIPT_DIR stores the absolute path to the hooks directory. -SCRIPT_DIR=$(dirname "$HOOK_PATH") -# PROJECT_ROOT stores the absolute path to the project root. -PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) - -# Enter Nix environment if osv-scanner or cargo is missing and Nix + flake are available -if [[ -z "${IN_NIX_SHELL:-}" ]] && [[ -z "${RESQ_NIX_GUARD:-}" ]]; then - if ! command -v osv-scanner >/dev/null 2>&1 || ! command -v cargo >/dev/null 2>&1; then - if command -v nix >/dev/null 2>&1 && [ -f "$PROJECT_ROOT/flake.nix" ]; then - echo "❄️ Entering Nix environment for pre-commit checks..." - export RESQ_NIX_GUARD=1 - exec nix develop "$PROJECT_ROOT" --command bash "$HOOK_PATH" "$@" - fi - fi -fi - -# Source shared utilities -if [ -f "$PROJECT_ROOT/scripts/lib/shell-utils.sh" ]; then - # shellcheck source=../scripts/lib/shell-utils.sh - source "$PROJECT_ROOT/scripts/lib/shell-utils.sh" -fi +set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# ── Delegate to resq CLI if available ──────────────────────────────────────── +PROJECT_ROOT="$(git rev-parse --show-toplevel)" + +# ── Resolve resq binary ───────────────────────────────────────────────────── +# PATH first (covers nix develop + ~/.cargo/bin on PATH). Soft-skip otherwise +# so a missing backend never blocks a commit silently — the user sees a hint. +RESQ_BIN="" if command -v resq >/dev/null 2>&1; then - exec resq pre-commit --root "$PROJECT_ROOT" "$@" + RESQ_BIN="resq" +elif [ -x "$HOME/.cargo/bin/resq" ]; then + RESQ_BIN="$HOME/.cargo/bin/resq" fi -echo "🔍 Running inline pre-commit checks (install resq-cli for full coverage)..." - -# ── Large file check ────────────────────────────────────────────────────────── -echo "🔍 Checking staged file sizes..." -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) -for f in $STAGED_FILES; do - if [ -f "$f" ]; then - SIZE=$(wc -c < "$f") - if [ "$SIZE" -gt 1048576 ]; then - echo "❌ File '$f' exceeds 1 MB limit (${SIZE} bytes)." - echo " Large files should not be committed to git." - exit 1 - fi - fi -done -echo "✅ File sizes OK" - -# ── Secrets scan ────────────────────────────────────────────────────────────── -echo "🔍 Scanning for secrets..." -if command -v gitleaks >/dev/null 2>&1; then - if ! gitleaks protect --staged --redact -v 2>/dev/null; then - echo "❌ Potential secrets detected by gitleaks. Remove them before committing." - exit 1 - fi +if [ -n "$RESQ_BIN" ]; then + "$RESQ_BIN" pre-commit --root "$PROJECT_ROOT" "$@" else - # Fallback: grep for common secret patterns in staged content - STAGED_CONTENT=$(git diff --cached --unified=0 2>/dev/null || true) - if echo "$STAGED_CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then - echo "❌ Potential AWS access key (AKIA...) found in staged changes." - exit 1 - fi - if echo "$STAGED_CONTENT" | grep -qE '(ghp_|ghs_|gho_)[A-Za-z0-9_]{36,}'; then - echo "❌ Potential GitHub token (ghp_/ghs_/gho_) found in staged changes." - exit 1 - fi - if echo "$STAGED_CONTENT" | grep -qiE 'api[_-]?key\s*=\s*"[A-Za-z0-9_\-]{16,}"'; then - echo "❌ Potential API key assignment found in staged changes." - exit 1 - fi - if echo "$STAGED_CONTENT" | grep -qF 'BEGIN PRIVATE KEY'; then - echo "❌ Private key material found in staged changes." - exit 1 - fi - if echo "$STAGED_CONTENT" | grep -qiE 'password\s*=\s*"[^"]{4,}"'; then - echo "❌ Potential hardcoded password found in staged changes." - exit 1 - fi + echo "⚠️ resq not found — skipping ResQ pre-commit checks." + echo " Install: enter 'nix develop', or run:" + echo " cargo install --git https://github.com/resq-software/crates resq-cli" fi -echo "✅ Secrets scan OK" -# ── OSV vulnerability scan ──────────────────────────────────────────────────── -if command -v osv-scanner >/dev/null 2>&1; then - echo "🔍 Scanning dependencies for known vulnerabilities..." - OSV_ARGS=() - # Rust lockfile - [ -f "$PROJECT_ROOT/Cargo.lock" ] && OSV_ARGS+=(--lockfile "$PROJECT_ROOT/Cargo.lock") - # JS lockfiles (programs may have a JS frontend or scripts) - for lockfile in bun.lock pnpm-lock.yaml package-lock.json; do - [ -f "$PROJECT_ROOT/$lockfile" ] && OSV_ARGS+=(--lockfile "$PROJECT_ROOT/$lockfile") - done - if [ ${#OSV_ARGS[@]} -gt 0 ]; then - if ! osv-scanner scan "${OSV_ARGS[@]}" 2>/dev/null; then - echo "❌ OSV scanner found vulnerabilities. Review and update dependencies." - exit 1 - fi - echo "✅ OSV scan OK" - fi +# ── Local override ────────────────────────────────────────────────────────── +LOCAL_HOOK="$PROJECT_ROOT/.git-hooks/local-pre-commit" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" fi - -# ── Rust formatting check ───────────────────────────────────────────────────── -STAGED_RS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rs$' || true) -if [ -n "$STAGED_RS" ] && command -v cargo >/dev/null 2>&1; then - echo "🔍 Checking Rust formatting..." - if ! cargo fmt --all -- --check 2>/dev/null; then - echo "❌ Rust formatting issues found. Run: cargo fmt --all" - exit 1 - fi - echo "✅ Rust formatting OK" -fi - -echo "✅ Pre-commit checks passed" diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 5b75005..5e2c70b 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -1,90 +1,98 @@ #!/usr/bin/env bash -set -euo pipefail -# -# Copyright 2026 ResQ -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# pre-push +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Runs checks before allowing a push to a remote repository. -# Includes force-push protection for main, branch naming convention enforcement, -# and cargo check for Rust/Anchor changes. -# -# Usage: -# .git-hooks/pre-push [remote_name [remote_url]] -# -# Arguments: -# $1 - Name of the remote to which the push is being done. -# $2 - URL to which the push is being done. -# -# Exit codes: -# 0 All checks passed. -# 1 Blocking check failed. +# Canonical ResQ pre-push shim — source: resq-software/dev. +# Force-push guard on main/master, branch naming convention, and an optional +# per-repo local-pre-push hook for language-specific checks +# (e.g. cargo check, ruff check, anchor build). + +set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 echo "🚀 Running pre-push checks..." -# BRANCH stores the name of the current local branch. -BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +# Deliberately no `BRANCH=$(git symbolic-ref HEAD)` here. Every rule below reads +# the ref being pushed to, and a variable holding the checked-out branch is what +# both of them used to key on — which is how a force push to main from another +# branch got waved through. Keeping it around only invites that back. -# ── Force-push guard for main ────────────────────────────────────────── -if [ "$BRANCH" = "main" ]; then - while read -r _local_ref local_sha _remote_ref remote_sha; do - if [ "$remote_sha" != "0000000000000000000000000000000000000000" ] && \ - [ "$local_sha" != "0000000000000000000000000000000000000000" ]; then - if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then - echo "❌ Force push to main is not allowed." - echo "To override: git push --no-verify" - exit 1 - fi - fi - done +# Capture stdin (the ref info git passes to pre-push) so both the force-push +# guard and any local-pre-push hook can read it. +PUSH_REFS="" +if [ ! -t 0 ]; then + PUSH_REFS=$(cat) fi -# ── Branch naming convention ─────────────────────────────────────────── -# ALLOWED_PATTERN is the regular expression for valid branch names. +# ── Per-ref guards: force-push protection and branch naming ───────────────── +# +# Both rules read the ref being *written to*, never the checked-out branch. +# git names them separately for a reason: `git push origin +feature/x:main` +# rewrites main while HEAD is feature/x, so a guard keyed on HEAD waves through +# exactly the push it exists to stop. The same mistake inverts the naming rule, +# checking `main` (skipped) instead of the branch actually being created. +ZERO_SHA="0000000000000000000000000000000000000000" ALLOWED_PATTERN="^(feat|fix|docs|chore|refactor|test|ci|perf|release)/.*$" -# SKIP_BRANCHES is the regular expression for branches exempt from naming checks. -SKIP_BRANCHES="^(main|master|dev|develop|staging|production)$" +SKIP_BRANCHES="^(main|master|dev|develop|staging|production|changeset-release/.*)$" -if [ -n "$BRANCH" ]; then - if ! echo "$BRANCH" | grep -qE "$SKIP_BRANCHES"; then - if ! echo "$BRANCH" | grep -qE "$ALLOWED_PATTERN"; then - echo "❌ Branch name '$BRANCH' does not follow naming convention." - echo "Expected: type/description (e.g., feat/add-auth, fix/login-bug)" - echo "Allowed prefixes: feat, fix, docs, chore, refactor, test, ci, perf, release" - echo "To push anyway: git push --no-verify" - exit 1 - fi - fi -fi +# Only iterate when git actually gave us refs — an empty $PUSH_REFS would +# otherwise feed one blank line through and be read as a record. +if [ -n "$PUSH_REFS" ]; then + while read -r _local_ref local_sha remote_ref remote_sha; do + [ -z "$remote_ref" ] && continue + + # Tags and other refs fall outside both rules. + case "$remote_ref" in + refs/heads/*) target="${remote_ref#refs/heads/}" ;; + *) continue ;; + esac -# ── cargo check (if Rust/Anchor files changed) ───────────────────────── -if command -v cargo >/dev/null 2>&1; then - REMOTE=${1:-origin} - REMOTE_BRANCH=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || echo "$REMOTE/main") - CHANGED_RS=$(git diff --name-only "$REMOTE_BRANCH"...HEAD -- '*.rs' 'Cargo.toml' 'Cargo.lock' 2>/dev/null || true) + # Force-push guard. Skipped when either side is the all-zero SHA: that + # is a branch being created or deleted, and there is no history to + # overwrite. + case "$target" in + main|master) + if [ "$remote_sha" != "$ZERO_SHA" ] && [ "$local_sha" != "$ZERO_SHA" ]; then + if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then + echo "❌ Force push to $target is not allowed." + echo " Override with: git push --no-verify" + exit 1 + fi + fi + ;; + esac - if [ -n "$CHANGED_RS" ]; then - echo " Checking Rust/Anchor workspace..." - if ! cargo check --workspace --quiet 2>&1; then - echo "❌ cargo check failed. Fix errors before pushing." - echo "To push anyway: git push --no-verify" - exit 1 + # Naming convention, on creates and updates only — deleting a + # badly-named branch is how you get rid of it. + if [ "$local_sha" != "$ZERO_SHA" ]; then + if ! grep -qE "$SKIP_BRANCHES" <<<"$target"; then + if ! grep -qE "$ALLOWED_PATTERN" <<<"$target"; then + echo "❌ Branch '$target' does not follow naming convention." + echo " Expected: type/description (e.g. feat/add-login)" + echo " Allowed prefixes: feat, fix, docs, chore, refactor, test, ci, perf, release" + echo " Override with: git push --no-verify" + exit 1 + fi + fi fi - echo "✅ Rust workspace OK" + done <&2 + exit 1 +fi + case "$COMMIT_SOURCE" in - merge|squash|message) exit 0 ;; + merge|squash|message|commit) exit 0 ;; esac -# Don't modify if amending -if [ "$COMMIT_SOURCE" = "commit" ]; then - exit 0 -fi - -# BRANCH stores the name of the current local branch. BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -if [ -z "$BRANCH" ]; then - exit 0 +[ -z "$BRANCH" ] && exit 0 + +TICKET=$(grep -oE '[A-Z]{2,}-[0-9]+' <<<"$BRANCH" | head -1 || true) +if [ -n "$TICKET" ]; then + COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + if ! grep -qF -- "$TICKET" <<<"$COMMIT_MSG"; then + printf '[%s] %s\n' "$TICKET" "$COMMIT_MSG" > "$COMMIT_MSG_FILE" + fi fi -# TICKET extracts the ticket reference (e.g., PROJ-123) from the branch name. -TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]{2,}-[0-9]+' | head -1 || true) - -if [ -z "$TICKET" ]; then - exit 0 +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-prepare-commit-msg" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" fi - -# Read current commit message -COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") - -# Don't prepend if ticket is already referenced in the message -if echo "$COMMIT_MSG" | grep -qF "$TICKET"; then - exit 0 -fi - -# Prepend ticket reference -printf '[%s] %s\n' "$TICKET" "$COMMIT_MSG" > "$COMMIT_MSG_FILE"