Solution — Git Hook Collection¶
Full reference implementations for all three hooks and the installer.
pre-commit¶
#!/usr/bin/env bash
# =============================================================================
# pre-commit — Run ShellCheck on staged .sh files; block trailing whitespace
# =============================================================================
set -euo pipefail
PASS=true
# ── ShellCheck ────────────────────────────────────────────────────────────────
check_shellcheck() {
if ! command -v shellcheck &>/dev/null; then
echo "[pre-commit] WARNING: shellcheck not installed — skipping lint"
return 0
fi
local staged
staged=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$' || true)
[[ -z "$staged" ]] && return 0
echo "[pre-commit] ShellCheck..."
local failed=0
while IFS= read -r file; do
if shellcheck "$file"; then
printf "[pre-commit] OK: %s\n" "$file"
else
printf "[pre-commit] FAIL: %s\n" "$file" >&2
(( failed++ )) || true
fi
done <<< "$staged"
if (( failed > 0 )); then
echo "[pre-commit] $failed file(s) failed ShellCheck." >&2
PASS=false
fi
}
# ── Trailing whitespace ───────────────────────────────────────────────────────
check_whitespace() {
local result
result=$(git diff --cached --check 2>&1 || true)
if [[ -n "$result" ]]; then
echo "[pre-commit] Trailing whitespace:" >&2
echo "$result" >&2
PASS=false
fi
}
check_shellcheck
check_whitespace
if [[ "$PASS" == false ]]; then
echo ""
echo "[pre-commit] Commit rejected. Fix issues above." >&2
exit 1
fi
echo "[pre-commit] All checks passed."
exit 0
commit-msg¶
#!/usr/bin/env bash
# =============================================================================
# commit-msg — Enforce conventional commit format: type[(scope)]: subject
# =============================================================================
set -euo pipefail
MSG_FILE="${1:?commit-msg: message file required}"
MSG="$(head -1 "$MSG_FILE")"
# Pass-through: merges, reverts, fixups
if echo "$MSG" | grep -qE '^(Merge|Revert|fixup!|squash!)'; then
exit 0
fi
TYPES="feat|fix|docs|style|refactor|test|chore|ci|build|perf"
PATTERN="^(${TYPES})(\([a-z0-9_/-]+\))?: .{1,72}$"
if echo "$MSG" | grep -qE "$PATTERN"; then
exit 0
fi
cat >&2 <<EOF
[commit-msg] Invalid commit message.
Got: "$MSG"
Expected: <type>[(scope)]: <subject> (max 72 chars)
Types: feat fix docs style refactor test chore ci build perf
Examples:
feat: add user authentication
fix(auth): handle expired tokens
docs: update README with examples
EOF
exit 1
pre-push¶
#!/usr/bin/env bash
# =============================================================================
# pre-push — Block non-fast-forward pushes to the main branch
# =============================================================================
set -euo pipefail
REMOTE="$1"
URL="$2"
PROTECTED="main"
PASS=true
while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
branch="${remote_ref#refs/heads/}"
[[ "$branch" == "$PROTECTED" ]] || continue
# remote_sha all-zeros means this is a new branch — allow it
if [[ "$remote_sha" == "0000000000000000000000000000000000000000" ]]; then
continue
fi
if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
cat >&2 <<EOF
[pre-push] Non-fast-forward push to '$PROTECTED' rejected.
[pre-push] Remote: $REMOTE ($URL)
[pre-push] If this is intentional, coordinate with your team first.
[pre-push] To override (not recommended): git push --no-verify
EOF
PASS=false
fi
done
[[ "$PASS" == false ]] && exit 1
exit 0
install.sh¶
#!/usr/bin/env bash
# =============================================================================
# install.sh — Symlink hooks into the current repo's .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 git hooks → $HOOKS_DIR"
echo ""
for hook in "${HOOKS[@]}"; do
src="${SCRIPT_DIR}/${hook}"
dst="${HOOKS_DIR}/${hook}"
if [[ ! -f "$src" ]]; then
printf " SKIP %-15s (not found)\n" "$hook"
continue
fi
if [[ -e "$dst" && ! -L "$dst" ]]; then
printf " BACK %-15s → %s.bak\n" "$hook" "$dst"
mv "$dst" "${dst}.bak"
fi
chmod +x "$src"
ln -sf "$src" "$dst"
printf " OK %-15s → %s\n" "$hook" "$dst"
done
echo ""
echo "Hooks installed. Test with: git commit --allow-empty -m 'test: verify hooks'"
Sample Output¶
$ bash install.sh
Installing git hooks → /home/user/my-project/.git/hooks
OK pre-commit → .git/hooks/pre-commit
OK commit-msg → .git/hooks/commit-msg
OK pre-push → .git/hooks/pre-push
Hooks installed. Test with: git commit --allow-empty -m 'test: verify hooks'
$ git commit -m "Fixed stuff"
[commit-msg] Invalid commit message.
Got: "Fixed stuff"
Expected: <type>[(scope)]: <subject> (max 72 chars)
...
error: failed to commit
Key Design Decisions¶
| Decision | Why |
|---|---|
| Symlinks in install.sh | Hook always runs latest version; no reinstall after edits |
--diff-filter=ACM |
Skip deleted files — ShellCheck can't run on removed files |
| Pass-through for merge commits | Merge messages are generated by git; shouldn't need a type prefix |
merge-base --is-ancestor |
Correct force-push detection; compares history, not just SHAs |
PASS=true flag pattern |
Run all checks before exiting so developer sees all failures at once |