Skip to content
Open
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
39 changes: 35 additions & 4 deletions .git-hooks/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
<!--
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.
-->

# 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).
Expand All @@ -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

Expand Down
83 changes: 38 additions & 45 deletions .git-hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
WomB0ComB0 marked this conversation as resolved.
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
85 changes: 85 additions & 0 deletions .git-hooks/local-pre-push
Original file line number Diff line number Diff line change
@@ -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
78 changes: 21 additions & 57 deletions .git-hooks/post-checkout
Original file line number Diff line number Diff line change
@@ -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
Comment thread
WomB0ComB0 marked this conversation as resolved.

# 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
Loading
Loading