diff --git a/README.md b/README.md index 7a52e9f..703b491 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,37 @@ swift test # Via xcodebuild (if using .xcodeproj) xcodebuild test -scheme FreeThinker ``` + +## Development Setup + +After cloning, install the git hooks: + +```bash +bash scripts/setup-hooks.sh +``` + +This symlinks everything in `git-hooks/` into `.git/hooks/`. It is idempotent — safe to re-run. + +### Commit Convention + +All commits must follow [Conventional Commits](https://www.conventionalcommits.org/) format: + +``` +(): +``` + +- **type** — one of: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `style`, `perf`, `ci` +- **scope** — optional, lowercase alphanumeric (e.g. `ui`, `model`, `deps`) +- **description** — lowercase imperative phrase, no trailing period, max 72 chars on the subject line +- **body** — optional; must be separated from the subject by a blank line + +Examples: + +``` +feat(ui): add dark mode toggle +fix: handle nil pointer in model loader +docs: update contributing guidelines +chore(deps): bump sparkle to 2.6.4 +``` + +The `commit-msg` hook (installed by `setup-hooks.sh`) validates this format automatically. diff --git a/git-hooks/commit-msg b/git-hooks/commit-msg new file mode 100755 index 0000000..f0c2fea --- /dev/null +++ b/git-hooks/commit-msg @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# commit-msg hook: enforces Conventional Commits format +# +# Format: (): +# - type: feat|fix|docs|chore|refactor|test|style|perf|ci +# - scope: optional, lowercase alphanumeric/hyphens in parentheses +# - description: lowercase imperative phrase, no trailing period +# - first line must be <=72 characters +# - if a body is present, a blank line must separate it from the subject + +set -euo pipefail + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE") + +TYPES="feat|fix|docs|chore|refactor|test|style|perf|ci" +PATTERN="^(${TYPES})(\([a-z0-9]([a-z0-9-]*[a-z0-9])?\))?!?: [a-z]([^.]|.*[^.])?$" + +error() { + printf '\n[commit-msg] ERROR: %s\n\n' "$*" >&2 +} + +# 1. Check Conventional Commits format +if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then + error "Commit message does not follow Conventional Commits format." + cat >&2 <<'HELP' + Expected format: (): + + Examples: + feat(ui): add dark mode toggle + fix: handle nil pointer in model loader + docs: update contributing guidelines + chore(deps): bump sparkle to 2.6.4 + + Allowed types: feat, fix, docs, chore, refactor, test, style, perf, ci + Rules: + - description must start with a lowercase letter + - description must be in imperative mood (e.g. "add" not "adds"/"added") + - no trailing period on the subject line + +HELP + exit 1 +fi + +# 2. Check subject line length (<= 72 chars) +SUBJECT_LEN=${#COMMIT_MSG} +if [[ "$SUBJECT_LEN" -gt 72 ]]; then + error "Subject line is ${SUBJECT_LEN} characters (max 72)." + printf ' Subject: %s\n\n' "$COMMIT_MSG" >&2 + exit 1 +fi + +# 3. If a body exists, verify blank line separates subject from body +LINE_COUNT=$(wc -l < "$COMMIT_MSG_FILE") +if [[ "$LINE_COUNT" -gt 1 ]]; then + SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE") + if [[ -n "$SECOND_LINE" ]]; then + error "Missing blank line between subject and body." + printf ' Add an empty line after the subject before the body text.\n\n' >&2 + exit 1 + fi +fi + +exit 0 diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100644 index 0000000..ed62f69 --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# setup-hooks.sh — install tracked git hooks into the active git hooks directory +# +# Run once after cloning: +# bash scripts/setup-hooks.sh +# +# Idempotent: safe to run multiple times. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOOKS_SOURCE="$REPO_ROOT/git-hooks" + +# Respect core.hooksPath when configured; fall back to .git/hooks +_configured_path="$(git -C "$REPO_ROOT" config core.hooksPath 2>/dev/null || true)" +if [[ -n "$_configured_path" ]]; then + # core.hooksPath may be relative — resolve it from the repo root + case "$_configured_path" in + /*) HOOKS_DEST="$_configured_path" ;; + *) HOOKS_DEST="$REPO_ROOT/$_configured_path" ;; + esac +else + HOOKS_DEST="$REPO_ROOT/.git/hooks" +fi + +if [[ ! -d "$HOOKS_SOURCE" ]]; then + printf '[setup-hooks] ERROR: %s not found. Run from repo root.\n' "$HOOKS_SOURCE" >&2 + exit 1 +fi + +if [[ ! -d "$HOOKS_DEST" ]]; then + printf '[setup-hooks] ERROR: %s not found. Is this a git repository?\n' "$HOOKS_DEST" >&2 + exit 1 +fi + +installed=0 +for hook in "$HOOKS_SOURCE"/*; do + [[ -f "$hook" ]] || continue + hook_name="$(basename "$hook")" + dest="$HOOKS_DEST/$hook_name" + + if [[ -L "$dest" ]]; then + # Already a symlink — update it in case source moved + rm "$dest" + elif [[ -f "$dest" ]]; then + printf '[setup-hooks] Backing up existing %s -> %s.bak\n' "$hook_name" "$hook_name" + mv "$dest" "${dest}.bak" + fi + + ln -s "$hook" "$dest" + chmod +x "$dest" + printf '[setup-hooks] Installed %s\n' "$hook_name" + (( installed++ )) || true +done + +if [[ "$installed" -eq 0 ]]; then + printf '[setup-hooks] No hooks found in %s\n' "$HOOKS_SOURCE" +else + printf '[setup-hooks] Done. %d hook(s) installed.\n' "$installed" +fi