diff --git a/Makefile b/Makefile index 088be01..152244e 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,12 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Install git hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hooks install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)" diff --git a/README.md b/README.md index 84f92cd..f9524a8 100644 --- a/README.md +++ b/README.md @@ -258,19 +258,38 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git hooks -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Install the git hooks to catch formatting, vet, build, and commit message issues before pushing: ```bash make install-hooks ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: +This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit` and `scripts/commit-msg.sh` into `.git/hooks/commit-msg`. + +The pre-commit hook runs: - **gofmt** — flags any staged `.go` files that need formatting - **go vet** — catches common correctness issues - **go build** — ensures the project compiles +The commit message hook accepts: + +```text +type: summary +type(scope): summary +``` + +Accepted types are `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `ci`, `perf`, `style`, and `revert`. The hook normalizes safe cases such as `FIX: thing`, `feat - thing`, and extra whitespace, while preserving the body and trailers. Merge, revert, `fixup!`, and `squash!` commits are allowed. + +Valid examples: + +```text +feat: add project setup command +fix(config): preserve default provider +docs: document hook installation +``` + To bypass in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 2c7dabb..393ec59 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -332,7 +332,7 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain Type: TaskCommitNormalize, Category: CategoryPR, Name: "Commit Message Normalizer", - Description: "Standardize commit message format", + Description: "Enforce repository commit message conventions via a git hook", CostTier: CostLow, RiskLevel: RiskLow, DefaultInterval: 24 * time.Hour, diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..c234d07 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg) +set -euo pipefail + +MSG_FILE=${1:-} +if [[ -z "$MSG_FILE" || ! -f "$MSG_FILE" ]]; then + echo "commit-msg: missing commit message file" >&2 + exit 1 +fi + +TYPES="feat|fix|docs|test|refactor|chore|build|ci|perf|style|revert" +SUBJECT_MAX=72 + +trim() { + local value=$1 + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +subject_line_number=$( + awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { print NR; exit } + ' "$MSG_FILE" +) + +if [[ -z "$subject_line_number" ]]; then + echo "commit-msg: empty commit message" >&2 + exit 1 +fi + +subject=$( + awk -v line="$subject_line_number" 'NR == line { print; exit }' "$MSG_FILE" +) +subject=$(trim "$subject") + +allow_special=false +case "$subject" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) + allow_special=true + ;; +esac + +normalized=$subject +if [[ "$allow_special" == false ]]; then + if [[ "$normalized" =~ ^([A-Za-z]+)(\([A-Za-z0-9._/-]+\))?[[:space:]]*[-:][[:space:]]*(.+)$ ]]; then + type_part=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') + scope_part=${BASH_REMATCH[2]:-} + summary_part=$(trim "${BASH_REMATCH[3]}") + normalized="${type_part}${scope_part}: ${summary_part}" + fi +fi + +if [[ "$normalized" != "$subject" ]]; then + tmp=$(mktemp) + awk -v line="$subject_line_number" -v replacement="$normalized" ' + NR == line { print replacement; next } + { print } + ' "$MSG_FILE" > "$tmp" + cat "$tmp" > "$MSG_FILE" + rm -f "$tmp" + subject=$normalized +fi + +if [[ "$allow_special" == true ]]; then + exit 0 +fi + +if ! [[ "$subject" =~ ^($TYPES)(\([A-Za-z0-9._/-]+\))?:[[:space:]][^[:space:]].*$ ]]; then + cat >&2 <<'EOF' +commit-msg: expected commit message format: + type: summary + type(scope): summary + +Accepted types: + feat, fix, docs, test, refactor, chore, build, ci, perf, style, revert + +Examples: + feat: add project setup command + fix(config): preserve default provider + docs: document hook installation + +Merge, Revert, fixup!, and squash! commits are allowed. +EOF + exit 1 +fi + +summary=${subject#*: } +if [[ -z "$(trim "$summary")" ]]; then + echo "commit-msg: summary must not be empty" >&2 + exit 1 +fi + +if (( ${#subject} > SUBJECT_MAX )); then + echo "commit-msg: subject must be ${SUBJECT_MAX} characters or fewer" >&2 + echo " ${subject}" >&2 + exit 1 +fi