Skip to content
Merged
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>(<scope>): <description>
```

- **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.
64 changes: 64 additions & 0 deletions git-hooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# commit-msg hook: enforces Conventional Commits format
#
# Format: <type>(<scope>): <description>
# - 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: <type>(<scope>): <description>

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
60 changes: 60 additions & 0 deletions scripts/setup-hooks.sh
Original file line number Diff line number Diff line change
@@ -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