From 824923c57d626e05617cebd6142ad72e9bf5d4bd Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Wed, 12 Aug 2026 01:55:05 -0400 Subject: [PATCH 1/4] chore(hooks): restore the canonical git hooks The hooks here predated the current shim architecture, and their headers had also been rewritten from `ResQ Software` to `ResQ Systems, Inc.`, so none matched the SHA-256 digests `install-hooks.sh` verifies before writing them. The rewrite was not a local edit: `resq copyright` defaults to the second author string and treats the difference as a mismatch, stripping each header and writing its own. Every repository in the org carrying hooks had drifted the same way. resq-software/crates#170 stops it at the source; this restores the files and sets `core.hooksPath`, without which the hooks never ran at all. Written by `resq hooks update`, so any `local-*` override is untouched. Verified byte-identical to `crates/resq-cli/templates/git-hooks`. Co-Authored-By: Claude Opus 5 (1M context) --- .git-hooks/commit-msg | 74 +++++++--------- .git-hooks/post-checkout | 73 ++++------------ .git-hooks/post-merge | 57 ++++-------- .git-hooks/pre-commit | 158 +++++----------------------------- .git-hooks/pre-push | 121 +++++++++++--------------- .git-hooks/prepare-commit-msg | 75 ++++------------ 6 files changed, 154 insertions(+), 404 deletions(-) diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg index 0fc26fd..8e62e20 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -1,62 +1,46 @@ #!/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. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Usage: -# .git-hooks/commit-msg COMMIT_MSG_FILE -# -# Arguments: -# $1 - Path to the file containing the commit message. -# -# 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:-}" 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") -if ! echo "$SUBJECT" | grep -qE "$PATTERN"; then +# 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 ! 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/post-checkout b/.git-hooks/post-checkout index 1ce8956..6627396 100755 --- a/.git-hooks/post-checkout +++ b/.git-hooks/post-checkout @@ -1,71 +1,30 @@ #!/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 + grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed — run: cargo build" + grep -q "^bun\.lockb\?$" <<<"$CHANGED" && echo "📦 bun.lock changed — run: bun install" + grep -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed — run: uv sync" + grep -q "^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..d95996f 100755 --- a/.git-hooks/post-merge +++ b/.git-hooks/post-merge @@ -1,49 +1,24 @@ #!/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 +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Automatically installs dependencies when lock files change after a merge. -# -# 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. + +set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# 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) +CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null || true) -# 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" -fi +grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed after merge — run: cargo build" +grep -q "^bun\.lockb\?$" <<<"$CHANGED" && echo "📦 bun.lock changed after merge — run: bun install" +grep -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed after merge — run: uv sync" +grep -q "^flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed after merge — exit and re-enter: nix develop" -# 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 +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..e1e868b 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -1,91 +1,74 @@ #!/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 -# -# 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. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# 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 "") -# ── 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. +# ── Force-push guard on main/master ───────────────────────────────────────── +case "$BRANCH" in + main|master) + # Only iterate when git actually gave us refs — an empty $PUSH_REFS + # would otherwise feed one blank line through the heredoc and trip + # the guard. + if [ -n "$PUSH_REFS" ]; then + while read -r _local_ref local_sha _remote_ref remote_sha; do + [ -z "$local_sha" ] && continue + 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 $BRANCH is not allowed." + echo " Override with: git push --no-verify" + exit 1 + fi + fi + done </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) - - 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 - fi - echo "✅ Rust workspace OK" - fi +# ── Local override (language-specific checks) ─────────────────────────────── +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-pre-push" +if [ -x "$LOCAL_HOOK" ]; then + # Re-supply captured stdin so local hooks that inspect refs still work. + "$LOCAL_HOOK" "$@" </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" From 8be786fb3d50e3a5cafb7022d30efb20dcccf684 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Thu, 13 Aug 2026 17:53:09 -0400 Subject: [PATCH 2/4] chore(hooks): restore Rust validation, update the README, pick up template fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.git-hooks/README.md` promised `cargo check --workspace` for pre-push and canonicalisation dropped the inline block delivering it. The canonical hook is language-agnostic by design and execs `local-pre-push` for exactly this, so the check moves there rather than back into a shared template. The README described the pre-canonical hooks throughout — auto `bun install` on checkout and merge, inline pre-commit logic — so it is rewritten to match what the hooks now do, including that the lockfile hooks report rather than mutate. Also re-syncs the canonical hooks after resq-software/crates#170. Co-Authored-By: Claude Opus 5 (1M context) --- .git-hooks/README.md | 38 +++++++++++++-- .git-hooks/commit-msg | 9 ++++ .git-hooks/local-pre-push | 49 +++++++++++++++++++ .git-hooks/post-checkout | 4 +- .git-hooks/post-merge | 4 +- .git-hooks/pre-push | 90 ++++++++++++++++++++++------------- .git-hooks/prepare-commit-msg | 7 +++ 7 files changed, 161 insertions(+), 40 deletions(-) create mode 100755 .git-hooks/local-pre-push diff --git a/.git-hooks/README.md b/.git-hooks/README.md index 45af17d..1b3099d 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,28 @@ 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 deliberately thin: each delegates +the real work to the `resq` binary or to a repo-owned `local-*` hook, which is +why 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 8e62e20..67d1289 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -10,6 +10,15 @@ set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 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)(\(.+\))?(!)?: .+$" FIRST_LINE=$(head -1 "$INPUT_FILE") diff --git a/.git-hooks/local-pre-push b/.git-hooks/local-pre-push new file mode 100755 index 0000000..36727e2 --- /dev/null +++ b/.git-hooks/local-pre-push @@ -0,0 +1,49 @@ +#!/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 + +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") +CHANGED_RS=$(git diff --name-only "$REMOTE_BRANCH"...HEAD -- '*.rs' 'Cargo.toml' 'Cargo.lock' 2>/dev/null || true) + +if [ -n "$CHANGED_RS" ]; then + echo " Rust: cargo check --workspace" + if ! cargo check --workspace --quiet; then + echo "❌ cargo check failed. Fix the errors 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 6627396..45a481f 100755 --- a/.git-hooks/post-checkout +++ b/.git-hooks/post-checkout @@ -17,7 +17,9 @@ if [ "$IS_BRANCH_CHECKOUT" = "1" ] && [ "$PREV_HEAD" != "$NEW_HEAD" ]; then CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" 2>/dev/null || true) grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed — run: cargo build" - grep -q "^bun\.lockb\?$" <<<"$CHANGED" && echo "📦 bun.lock changed — run: bun install" + # `\?` 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 -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed — run: uv sync" grep -q "^flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed — exit and re-enter: nix develop" fi diff --git a/.git-hooks/post-merge b/.git-hooks/post-merge index d95996f..b0e5867 100755 --- a/.git-hooks/post-merge +++ b/.git-hooks/post-merge @@ -12,7 +12,9 @@ set -euo pipefail CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null || true) grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed after merge — run: cargo build" -grep -q "^bun\.lockb\?$" <<<"$CHANGED" && echo "📦 bun.lock changed after merge — run: bun install" +# `\?` 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 -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed after merge — run: uv sync" grep -q "^flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed after merge — exit and re-enter: nix develop" diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index e1e868b..72804af 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -22,53 +22,75 @@ if [ ! -t 0 ]; then PUSH_REFS=$(cat) fi -# ── Force-push guard on main/master ───────────────────────────────────────── -case "$BRANCH" in - main|master) - # Only iterate when git actually gave us refs — an empty $PUSH_REFS - # would otherwise feed one blank line through the heredoc and trip - # the guard. - if [ -n "$PUSH_REFS" ]; then - while read -r _local_ref local_sha _remote_ref remote_sha; do - [ -z "$local_sha" ] && continue - if [ "$remote_sha" != "0000000000000000000000000000000000000000" ] && \ - [ "$local_sha" != "0000000000000000000000000000000000000000" ]; then +# ── 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="^(main|master|dev|develop|staging|production|changeset-release/.*)$" + +# 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 + + # 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 $BRANCH is not allowed." + echo "❌ Force push to $target is not allowed." echo " Override with: git push --no-verify" exit 1 fi fi - done <&2 + exit 1 +fi + case "$COMMIT_SOURCE" in merge|squash|message|commit) exit 0 ;; esac From 442b2cabff8d10d1e94bceede7d008c66472d17e Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Thu, 13 Aug 2026 18:51:07 -0400 Subject: [PATCH 3/4] chore(hooks): resync pre-push after the shellcheck fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the dead BRANCH variable (SC2034) from the canonical template. Behaviour is unchanged — every rule already read the pushed ref. Co-Authored-By: Claude Opus 5 (1M context) --- .git-hooks/pre-push | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 72804af..5e2c70b 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -13,7 +13,10 @@ set -euo pipefail echo "🚀 Running pre-push checks..." -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. # 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. From b61351bd46dd46d33580f9e996db85b08ce6997f Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Thu, 13 Aug 2026 19:05:16 -0400 Subject: [PATCH 4/4] =?UTF-8?q?chore(hooks):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20ref-aware=20scoping,=20nested=20lockfiles,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical templates re-synced after resq-software/crates#170: lockfile patterns now match nested workspaces, and post-merge handles `git merge --squash`, where HEAD has not moved and a tree-to-tree diff sees nothing. Where this repo has a local-pre-push, it now reads the ref records the canonical hook forwards instead of assuming the push is of the checked-out branch. The check compiles the working tree, so it cannot speak for a commit that is not checked out — it therefore scopes conservatively, running whenever a pushed ref is not HEAD and when change detection fails, rather than silently skipping. Co-Authored-By: Claude Opus 5 (1M context) --- .git-hooks/README.md | 7 ++--- .git-hooks/local-pre-push | 56 ++++++++++++++++++++++++++++++++------- .git-hooks/post-checkout | 11 +++++--- .git-hooks/post-merge | 21 +++++++++++---- 4 files changed, 73 insertions(+), 22 deletions(-) diff --git a/.git-hooks/README.md b/.git-hooks/README.md index 1b3099d..47ddea2 100644 --- a/.git-hooks/README.md +++ b/.git-hooks/README.md @@ -34,9 +34,10 @@ The setup script sets `core.hooksPath` to `.git-hooks` and makes all hooks execu 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 deliberately thin: each delegates -the real work to the `resq` binary or to a repo-owned `local-*` hook, which is -why editing them here only produces drift. +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 | |------|---------| diff --git a/.git-hooks/local-pre-push b/.git-hooks/local-pre-push index 36727e2..8845489 100755 --- a/.git-hooks/local-pre-push +++ b/.git-hooks/local-pre-push @@ -32,16 +32,52 @@ if ! command -v cargo >/dev/null 2>&1; then exit 0 fi -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") -CHANGED_RS=$(git diff --name-only "$REMOTE_BRANCH"...HEAD -- '*.rs' 'Cargo.toml' 'Cargo.lock' 2>/dev/null || true) - -if [ -n "$CHANGED_RS" ]; then - echo " Rust: cargo check --workspace" - if ! cargo check --workspace --quiet; then - echo "❌ cargo check failed. Fix the errors before pushing." +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 diff --git a/.git-hooks/post-checkout b/.git-hooks/post-checkout index 45a481f..593a3c6 100755 --- a/.git-hooks/post-checkout +++ b/.git-hooks/post-checkout @@ -16,12 +16,15 @@ IS_BRANCH_CHECKOUT="${3:-}" if [ "$IS_BRANCH_CHECKOUT" = "1" ] && [ "$PREV_HEAD" != "$NEW_HEAD" ]; then CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" 2>/dev/null || true) - grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed — run: cargo build" + # 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 -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed — run: uv sync" - grep -q "^flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed — exit and re-enter: nix develop" + 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 LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-post-checkout" diff --git a/.git-hooks/post-merge b/.git-hooks/post-merge index b0e5867..f09d1bf 100755 --- a/.git-hooks/post-merge +++ b/.git-hooks/post-merge @@ -9,14 +9,25 @@ set -euo pipefail [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null || true) +# 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 -grep -q "^Cargo\.lock$" <<<"$CHANGED" && echo "📦 Cargo.lock changed after merge — run: cargo build" +# 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 -q "^uv\.lock$" <<<"$CHANGED" && echo "📦 uv.lock changed after merge — run: uv sync" -grep -q "^flake\.lock$" <<<"$CHANGED" && echo "📦 flake.lock changed after merge — exit and re-enter: nix develop" +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