diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg index dc478e9..8e62e20 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -1,46 +1,46 @@ #!/usr/bin/env bash -# Copyright 2026 ResQ +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# 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. +# 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. -PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+$" +INPUT_FILE="${1:-}" +PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+$" -# Validate only the first line (subject). 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 master -BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -if [ "$BRANCH" = "master" ]; 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 master." - 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-commit b/.git-hooks/local-pre-commit new file mode 100755 index 0000000..441ed5d --- /dev/null +++ b/.git-hooks/local-pre-commit @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Repo-specific pre-commit for the resq-cli home repo. +# +# The canonical pre-commit delegates to `resq pre-commit` but only looks on +# PATH and ~/.cargo/bin. This repo *ships* resq-cli, so a contributor may be +# working with a workspace-local build at target/{release,debug}/resq and not +# have the binary installed globally. Runs only when PATH + cargo lookup both +# failed — keeps the canonical contract while preserving the old behavior. +set -euo pipefail + +# If PATH or ~/.cargo/bin already has resq, the canonical already ran it. +if command -v resq >/dev/null 2>&1 || [ -x "$HOME/.cargo/bin/resq" ]; then + exit 0 +fi + +PROJECT_ROOT="$(git rev-parse --show-toplevel)" +if [ -x "$PROJECT_ROOT/target/release/resq" ]; then + RESQ_BIN="$PROJECT_ROOT/target/release/resq" +elif [ -x "$PROJECT_ROOT/target/debug/resq" ]; then + RESQ_BIN="$PROJECT_ROOT/target/debug/resq" +elif command -v cargo >/dev/null 2>&1; then + echo "📦 Building resq-cli from workspace for pre-commit checks..." + cargo build -q -p resq-cli + RESQ_BIN="$PROJECT_ROOT/target/debug/resq" +else + echo "⚠️ resq not on PATH and cargo not available. Install it or run from nix develop." >&2 + exit 0 +fi + +exec "$RESQ_BIN" pre-commit --root "$PROJECT_ROOT" "$@" diff --git a/.git-hooks/post-checkout b/.git-hooks/post-checkout index 440eca6..6627396 100755 --- a/.git-hooks/post-checkout +++ b/.git-hooks/post-checkout @@ -1,29 +1,11 @@ #!/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 +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# Automatically notifies when lock files change during branch checkout. -# -# Usage: -# .git-hooks/post-checkout PREV_HEAD NEW_HEAD IS_BRANCH_CHECKOUT -# -# 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 @@ -31,11 +13,18 @@ PREV_HEAD="${1:-}" NEW_HEAD="${2:-}" IS_BRANCH_CHECKOUT="${3:-}" -if [ "$IS_BRANCH_CHECKOUT" != "1" ]; then exit 0; fi -if [ "$PREV_HEAD" = "$NEW_HEAD" ]; 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) -CHANGED=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" 2>/dev/null) + 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 -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 + +exit 0 diff --git a/.git-hooks/post-merge b/.git-hooks/post-merge index 5f540d2..d95996f 100755 --- a/.git-hooks/post-merge +++ b/.git-hooks/post-merge @@ -1,34 +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 notifies 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=$(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) + +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" -if echo "$CHANGED_FILES" | grep -q "^Cargo\.lock$"; then - echo "📦 Cargo.lock changed after merge — dependencies will update on next build" +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 621bc43..dd24586 100755 --- a/.git-hooks/pre-commit +++ b/.git-hooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 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. @@ -7,49 +7,36 @@ # # 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. +# 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. set -euo pipefail -# 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 - -# 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) - [ -n "${GIT_HOOKS_SKIP:-}" ] && exit 0 -# ── Resolve resq CLI binary ───────────────────────────────────────────────── +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 RESQ_BIN="resq" -elif [ -f "$PROJECT_ROOT/target/release/resq" ]; then - RESQ_BIN="$PROJECT_ROOT/target/release/resq" -elif [ -f "$PROJECT_ROOT/target/debug/resq" ]; then - RESQ_BIN="$PROJECT_ROOT/target/debug/resq" -elif command -v cargo >/dev/null 2>&1; then - echo "📦 Building resq CLI for pre-commit checks..." - cargo build -q -p resq-cli - RESQ_BIN="$PROJECT_ROOT/target/debug/resq" +elif [ -x "$HOME/.cargo/bin/resq" ]; then + RESQ_BIN="$HOME/.cargo/bin/resq" fi -if [ -z "$RESQ_BIN" ]; then - echo "⚠️ ResQ CLI not found and cannot be built. Skipping pre-commit checks." - exit 0 +if [ -n "$RESQ_BIN" ]; then + "$RESQ_BIN" pre-commit --root "$PROJECT_ROOT" "$@" +else + 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 -# ── Delegate to Rust CLI ───────────────────────────────────────────────────── -# This runs: copyright, secrets, audit, formatting, and versioning prompt -exec "$RESQ_BIN" pre-commit --root "$PROJECT_ROOT" "$@" +# ── Local override ────────────────────────────────────────────────────────── +LOCAL_HOOK="$PROJECT_ROOT/.git-hooks/local-pre-commit" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" +fi diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 8600da4..e1e868b 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -1,32 +1,13 @@ #!/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. +# Copyright 2026 ResQ Software +# SPDX-License-Identifier: Apache-2.0 # -# pre-push -# -# Runs checks before allowing a push to a remote repository. -# Includes force-push protection for master, branch naming convention -# enforcement, and cargo check for Rust changes. -# -# Usage: -# .git-hooks/pre-push [remote_name [remote_url]] -# -# 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 @@ -34,51 +15,60 @@ echo "🚀 Running pre-push checks..." BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") -# ── Force-push guard for master ──────────────────────────────────────── -if [ "$BRANCH" = "master" ]; 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 master 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 ─────────────────────────────────────────── +# ── 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/master") - 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 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; fi +[ -z "$BRANCH" ] && exit 0 -TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]{2,}-[0-9]+' | head -1 || true) -if [ -z "$TICKET" ]; then exit 0; fi - -COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") -if echo "$COMMIT_MSG" | grep -qF "$TICKET"; then exit 0; fi +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 -printf '[%s] %s\n' "$TICKET" "$COMMIT_MSG" > "$COMMIT_MSG_FILE" +LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-prepare-commit-msg" +if [ -x "$LOCAL_HOOK" ]; then + exec "$LOCAL_HOOK" "$@" +fi diff --git a/.github/workflows/hooks-sync.yml b/.github/workflows/hooks-sync.yml new file mode 100644 index 0000000..c3152de --- /dev/null +++ b/.github/workflows/hooks-sync.yml @@ -0,0 +1,119 @@ +# Copyright 2026 ResQ +# SPDX-License-Identifier: Apache-2.0 +# +# Validate the canonical git hook templates shipped by resq-cli: +# - shellcheck + bash-parse every template +# - ensure crates/resq-cli/templates/git-hooks/ matches .git-hooks/ +# (the crates repo eats its own dogfood — both must stay in sync) +# - diff against resq-software/dev@main scripts/git-hooks/ (dev mirrors +# the same templates until Phase 4 retires that copy) + +name: hooks-sync + +on: + push: + branches: [master] + paths: + - 'crates/resq-cli/templates/git-hooks/**' + - '.git-hooks/**' + - '.github/workflows/hooks-sync.yml' + pull_request: + paths: + - 'crates/resq-cli/templates/git-hooks/**' + - '.git-hooks/**' + - '.github/workflows/hooks-sync.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: shellcheck + parse + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update -y && sudo apt-get install -y shellcheck + + - name: Run shellcheck on templates + run: | + shellcheck -S warning \ + crates/resq-cli/templates/git-hooks/pre-commit \ + crates/resq-cli/templates/git-hooks/commit-msg \ + crates/resq-cli/templates/git-hooks/prepare-commit-msg \ + crates/resq-cli/templates/git-hooks/pre-push \ + crates/resq-cli/templates/git-hooks/post-checkout \ + crates/resq-cli/templates/git-hooks/post-merge + + - name: bash -n parse check (templates + installed hooks) + run: | + set -e + for f in \ + crates/resq-cli/templates/git-hooks/pre-commit \ + crates/resq-cli/templates/git-hooks/commit-msg \ + crates/resq-cli/templates/git-hooks/prepare-commit-msg \ + crates/resq-cli/templates/git-hooks/pre-push \ + crates/resq-cli/templates/git-hooks/post-checkout \ + crates/resq-cli/templates/git-hooks/post-merge \ + .git-hooks/pre-commit \ + .git-hooks/commit-msg \ + .git-hooks/prepare-commit-msg \ + .git-hooks/pre-push \ + .git-hooks/post-checkout \ + .git-hooks/post-merge; do + [ -f "$f" ] && bash -n "$f" + done + + drift-check-local: + name: templates vs .git-hooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Diff crates/resq-cli/templates/git-hooks/ vs .git-hooks/ + run: | + rc=0 + for h in pre-commit commit-msg prepare-commit-msg pre-push post-checkout post-merge; do + if ! diff -u "crates/resq-cli/templates/git-hooks/$h" ".git-hooks/$h"; then + echo "::error file=.git-hooks/$h::drifts from crates/resq-cli/templates/git-hooks/$h" + rc=1 + fi + done + if [ "$rc" -ne 0 ]; then + echo "::error::.git-hooks/ and crates/resq-cli/templates/git-hooks/ must be byte-identical." + echo "::error::Fix by copying one into the other: cp crates/resq-cli/templates/git-hooks/* .git-hooks/" + exit 1 + fi + echo "✅ .git-hooks/ matches crates/resq-cli/templates/git-hooks/" + + drift-check-dev: + name: templates vs resq-software/dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch canonical hooks from resq-software/dev@main + run: | + mkdir -p /tmp/dev-templates + for h in pre-commit commit-msg prepare-commit-msg pre-push post-checkout post-merge; do + curl -fsSL \ + "https://raw.githubusercontent.com/resq-software/dev/main/scripts/git-hooks/$h" \ + -o "/tmp/dev-templates/$h" + done + + - name: Diff crates/resq-cli/templates/git-hooks/ vs dev/scripts/git-hooks/ + run: | + rc=0 + for h in pre-commit commit-msg prepare-commit-msg pre-push post-checkout post-merge; do + if ! diff -u "crates/resq-cli/templates/git-hooks/$h" "/tmp/dev-templates/$h"; then + echo "::error file=crates/resq-cli/templates/git-hooks/$h::drifts from resq-software/dev@main:scripts/git-hooks/$h" + rc=1 + fi + done + if [ "$rc" -ne 0 ]; then + echo "::error::Canonical hook templates in crates/ and dev/ must be byte-identical." + exit 1 + fi + echo "✅ Templates match resq-software/dev@main" diff --git a/crates/resq-cli/templates/git-hooks/commit-msg b/crates/resq-cli/templates/git-hooks/commit-msg index 6c241f4..8e62e20 100755 --- a/crates/resq-cli/templates/git-hooks/commit-msg +++ b/crates/resq-cli/templates/git-hooks/commit-msg @@ -13,21 +13,16 @@ INPUT_FILE="${1:-}" PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+$" FIRST_LINE=$(head -1 "$INPUT_FILE") -SUBJECT=$(sed 's/^\[[A-Z][A-Z]*-[0-9]*\][[:space:]]*//' <<<"$FIRST_LINE") - -if ! grep -qE "$PATTERN" <<<"$SUBJECT"; then - echo "Error: Invalid commit message format." - echo "Expected: type(scope): subject" - echo "Examples:" - echo " feat(core): add new feature" - echo " fix(ui): fix button color" - exit 1 -fi +# 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]+-[0-9]+\] )?(fixup!|squash!|wip[: ])" <<<"$FIRST_LINE"; then + 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 @@ -35,6 +30,16 @@ case "$BRANCH" in ;; esac +if ! grep -qE "$PATTERN" <<<"$SUBJECT"; then + echo "Error: Invalid commit message format." + 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 + LOCAL_HOOK="$(git rev-parse --show-toplevel)/.git-hooks/local-commit-msg" if [ -x "$LOCAL_HOOK" ]; then exec "$LOCAL_HOOK" "$@" diff --git a/crates/resq-cli/templates/git-hooks/pre-push b/crates/resq-cli/templates/git-hooks/pre-push index ca5d9b0..e1e868b 100755 --- a/crates/resq-cli/templates/git-hooks/pre-push +++ b/crates/resq-cli/templates/git-hooks/pre-push @@ -25,18 +25,24 @@ fi # ── Force-push guard on main/master ───────────────────────────────────────── case "$BRANCH" in main|master) - 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 $BRANCH is not allowed." - echo " Override with: git push --no-verify" - exit 1 + # 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 - fi - done <