Implementation — Git Hook Collection¶
Build each hook independently, then wire them together with the installer.
Hook 1 — pre-commit¶
Runs ShellCheck on staged .sh files and rejects commits that add trailing whitespace.
#!/usr/bin/env bash
# pre-commit — ShellCheck staged .sh files and block trailing whitespace
set -euo pipefail
PASS=true
# ── ShellCheck on staged shell scripts ───────────────────────────────────────
check_shellcheck() {
if ! command -v shellcheck &>/dev/null; then
echo "[pre-commit] WARNING: shellcheck not installed — skipping"
return 0
fi
local staged_scripts
staged_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' || true)
[[ -z "$staged_scripts" ]] && return 0
echo "[pre-commit] Running ShellCheck..."
local failed=0
while IFS= read -r file; do
if shellcheck "$file"; then
echo "[pre-commit] OK: $file"
else
echo "[pre-commit] FAIL: $file" >&2
(( failed++ )) || true
fi
done <<< "$staged_scripts"
if (( failed > 0 )); then
echo "[pre-commit] $failed file(s) failed ShellCheck. Fix errors before committing." >&2
PASS=false
fi
}
# ── Trailing whitespace check ─────────────────────────────────────────────────
check_trailing_whitespace() {
local offending
offending=$(git diff --cached --check 2>&1 || true)
if [[ -n "$offending" ]]; then
echo "[pre-commit] Trailing whitespace detected:" >&2
echo "$offending" >&2
echo "[pre-commit] Fix with: git diff --cached | grep -n ' $'" >&2
PASS=false
fi
}
# ── Run checks ────────────────────────────────────────────────────────────────
check_shellcheck
check_trailing_whitespace
if [[ "$PASS" == false ]]; then
echo ""
echo "[pre-commit] Commit rejected. Fix the above issues and try again." >&2
exit 1
fi
echo "[pre-commit] All checks passed."
exit 0
git diff --check
Git's own --check flag detects trailing whitespace and conflict markers in staged content. Using it avoids writing your own whitespace regex.
Hook 2 — commit-msg¶
Enforces the conventional commit format: type: subject.
#!/usr/bin/env bash
# commit-msg — Enforce conventional commit message format
set -euo pipefail
MSG_FILE="${1:?commit-msg: no message file provided}"
MSG="$(head -1 "$MSG_FILE")"
# Skip merge commits, fixups, and squash commits
if echo "$MSG" | grep -qE '^(Merge|Revert|fixup!|squash!)'; then
exit 0
fi
VALID_TYPES="feat|fix|docs|style|refactor|test|chore|ci|build|perf"
PATTERN="^(${VALID_TYPES})(\([a-z0-9_-]+\))?: .{1,72}$"
if echo "$MSG" | grep -qE "$PATTERN"; then
exit 0
fi
cat >&2 <<EOF
[commit-msg] Invalid commit message format.
Got: "$MSG"
Expected: <type>: <subject> (max 72 chars)
Valid types: feat fix docs style refactor test chore ci build perf
Examples:
feat: add user login
fix(auth): handle expired tokens
docs: update README
EOF
exit 1
Allow scope in parentheses
feat(auth): add OAuth2 support — the (\([a-z0-9_-]+\))? part makes the scope optional. The parentheses are escaped as \( in the regex because we're using grep -E.
Hook 3 — pre-push¶
Blocks pushes to main unless they are fast-forward (prevents accidental force-push):
#!/usr/bin/env bash
# pre-push — Block force-pushes and direct pushes to main
set -euo pipefail
REMOTE="$1"
URL="$2"
PROTECTED_BRANCH="main"
PASS=true
echo "[pre-push] Remote: $REMOTE ($URL)"
while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
# Extract just the branch name from refs/heads/main
branch="${remote_ref#refs/heads/}"
if [[ "$branch" != "$PROTECTED_BRANCH" ]]; then
continue
fi
# Detect force-push: remote_sha exists and is not an ancestor of local_sha
if [[ "$remote_sha" != "0000000000000000000000000000000000000000" ]]; then
if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
echo "[pre-push] ERROR: Non-fast-forward push to '$PROTECTED_BRANCH' rejected." >&2
echo "[pre-push] If this is intentional, use --no-verify (requires team lead approval)." >&2
PASS=false
fi
fi
# Warn if pushing large files
local large_files
large_files=$(git diff --name-only "$remote_sha..$local_sha" 2>/dev/null |
xargs -I{} sh -c 'test -f "{}" && du -sk "{}"' 2>/dev/null |
awk '$1 > 5120 {print $2}' || true) # > 5 MB
if [[ -n "$large_files" ]]; then
echo "[pre-push] WARNING: Large file(s) being pushed:" >&2
echo "$large_files" | while read -r f; do echo " $f"; done >&2
fi
done
[[ "$PASS" == false ]] && exit 1
exit 0
The Installer — install.sh¶
#!/usr/bin/env bash
# install.sh — Symlink hooks into .git/hooks/
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GIT_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "")"
[[ -z "$GIT_ROOT" ]] && { echo "Error: not inside a git repository" >&2; exit 1; }
HOOKS_DIR="${GIT_ROOT}/.git/hooks"
HOOKS=("pre-commit" "commit-msg" "pre-push")
echo "Installing hooks from $SCRIPT_DIR → $HOOKS_DIR"
for hook in "${HOOKS[@]}"; do
local src="${SCRIPT_DIR}/${hook}"
local dst="${HOOKS_DIR}/${hook}"
[[ -f "$src" ]] || { echo " SKIP $hook (not found in $SCRIPT_DIR)"; continue; }
if [[ -e "$dst" && ! -L "$dst" ]]; then
echo " WARN $hook: existing hook at $dst — backing up as ${dst}.bak"
mv "$dst" "${dst}.bak"
fi
ln -sf "$src" "$dst"
chmod +x "$src"
echo " OK $hook → $dst"
done
echo "Done. Run 'git log --oneline -1' to verify hooks are active."
Symlinks, not copies
Symlinking means the hook always runs the latest version from your project directory. If you copied the file, you'd have to reinstall after every edit.