diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5686..5ed822f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,47 @@ on: branches: [main] jobs: + commit-messages: + name: Commit Messages + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate commit messages + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + range="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" + else + before="${{ github.event.before }}" + if [[ "$before" == "0000000000000000000000000000000000000000" ]]; then + range="${{ github.sha }}" + else + range="$before..${{ github.sha }}" + fi + fi + + commits=$(git rev-list --no-merges "$range") + if [[ -z "$commits" ]]; then + echo "No non-merge commits to validate." + exit 0 + fi + + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + + while IFS= read -r commit; do + [[ -n "$commit" ]] || continue + git show -s --format=%B "$commit" > "$tmp" + echo "check $commit" + scripts/commit-msg.sh "$tmp" + done <<< "$commits" + test: name: Test runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 088be01..dc1c944 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,14 @@ 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 pre-commit and commit-msg hooks" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hooks install-hooks: + @mkdir -p .git/hooks @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @echo "✓ hooks installed" + @echo " .git/hooks/pre-commit -> scripts/pre-commit.sh" + @echo " .git/hooks/commit-msg -> scripts/commit-msg.sh" diff --git a/README.md b/README.md index 84f92cd..03a627f 100644 --- a/README.md +++ b/README.md @@ -258,20 +258,56 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Commit messages -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Use `type(scope): summary` or `type: summary` for new commits. Allowed types: +`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, +`revert`, `style`, `test`. + +Keep the summary short, imperative, and without a trailing period. If you add a +body or trailers, leave line 2 blank. + +GitHub squash merges may append `(#123)` to the subject on `main`; that suffix +is accepted. + +Valid examples: + +```text +fix: install commit-msg hook +feat(tasks): add commit metadata trailers +docs: explain contributor hook setup + +fix: normalize commit subjects + +Allow release and merge commit exemptions. + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +``` + +Allowed exceptions: +- Merge commits generated by Git +- Release/version commits like `Bump version to v0.3.5` or `Release v0.3.5: ...` + +### Git hooks + +Install the local git hooks before contributing: ```bash make install-hooks ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: -- **gofmt** — flags any staged `.go` files that need formatting -- **go vet** — catches common correctness issues -- **go build** — ensures the project compiles +This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit` and +`scripts/commit-msg.sh` into `.git/hooks/commit-msg`. + +The hooks run: +- **pre-commit** — `gofmt`, `go vet`, `go build` +- **commit-msg** — validates the commit subject/body layout above + +CI runs the same commit-message validator for non-merge commits in PRs and +pushes, so web edits or `--no-verify` commits still have to match the standard. -To bypass in a pinch: `git commit --no-verify` +To bypass the local hooks in a pinch: `git commit --no-verify` ## Uninstalling diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..981f5f5 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,65 @@ +#!/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 + +TYPES="build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test" + +usage() { + echo "usage: $0 " >&2 + exit 1 +} + +fail() { + echo "commit-msg: $1" >&2 + echo "use: type(scope): summary" >&2 + echo "types: build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test" >&2 + echo "exceptions: merge commits, Bump version..., Release v..." >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage + +msg_file="$1" +[[ -f "$msg_file" ]] || usage + +lines=() +while IFS= read -r line || [[ -n "$line" ]]; do + line=${line%$'\r'} + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + lines+=("$line") +done < "$msg_file" + +while [[ ${#lines[@]} -gt 0 ]]; do + last_index=$((${#lines[@]} - 1)) + [[ -n "${lines[$last_index]}" ]] && break + unset 'lines[$last_index]' +done + +[[ ${#lines[@]} -gt 0 ]] || fail "empty commit message" + +subject="${lines[0]}" + +merge_re='^Merge ' +revert_re='^Revert ".*"$' +release_bump_re='^Bump version to v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*([[:space:]]+\(#[0-9]+\))?$' +release_re='^Release v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*(: .+)?([[:space:]]+\(#[0-9]+\))?$' + +if [[ "$subject" =~ $merge_re ]] || [[ "$subject" =~ $revert_re ]] || [[ "$subject" =~ $release_bump_re ]] || [[ "$subject" =~ $release_re ]]; then + exit 0 +fi + +subject_re="^(${TYPES})(\\([A-Za-z0-9#][A-Za-z0-9._/#-]*\\))?(!)?: .+$" +[[ "$subject" =~ $subject_re ]] || fail "expected Conventional Commits subject" + +subject_core="$subject" +if [[ "$subject_core" =~ ^(.+)\ \(#[0-9]+\)$ ]]; then + subject_core="${BASH_REMATCH[1]}" +fi +[[ "$subject_core" != *. ]] || fail "subject must not end with a period" + +if [[ ${#lines[@]} -gt 1 ]] && [[ -n "${lines[1]}" ]]; then + fail "leave line 2 blank before body or trailers" +fi