diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5686..ab6fa7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,32 @@ 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 for pull requests + if: github.event_name == 'pull_request' + run: scripts/commit-msg.sh --rev-range "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + + - name: Validate commit messages for pushes + if: github.event_name == 'push' + env: + BEFORE_SHA: ${{ github.event.before }} + CURRENT_SHA: ${{ github.sha }} + run: | + if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then + rev_spec="$CURRENT_SHA" + else + rev_spec="$BEFORE_SHA..$CURRENT_SHA" + fi + scripts/commit-msg.sh --rev-range "$rev_spec" + test: name: Test runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 088be01..f25b7dd 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 pre-commit and commit-msg 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..24a7b76 100644 --- a/README.md +++ b/README.md @@ -258,20 +258,60 @@ 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 local git hooks to catch build issues and invalid commit subjects before pushing: ```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` +- `scripts/commit-msg.sh` into `.git/hooks/commit-msg` -To bypass in a pinch: `git commit --no-verify` +The hooks run: +- **pre-commit** — `gofmt`, `go vet`, and `go build` +- **commit-msg** — validates the first line of each new commit message + +### Commit message format + +Nightshift standardizes future commit subjects only. Existing history stays as-is. + +Required format: + +```text +type(scope?): summary +``` + +Allowed types: +- `build` +- `chore` +- `ci` +- `docs` +- `feat` +- `fix` +- `perf` +- `refactor` +- `revert` +- `style` +- `test` + +Valid examples: +- `feat(cli): add commit message validator` +- `fix: handle pull request commit ranges in CI` +- `docs(readme): document local hook installation` + +These exception subjects remain valid: +- `Merge pull request #17 from owner/branch` +- `Revert "feat(cli): add commit message validator"` +- `Bump version to v0.3.4` +- `Release v0.3.4: changelog and binaries` + +If the `commit-msg` hook fails: +- Amend the subject with `git commit --amend -m "fix(cli): normalize commit message validation"` +- Re-run the validator with `scripts/commit-msg.sh .git/COMMIT_EDITMSG` +- Bypass the hooks in a pinch with `git commit --no-verify` ## Uninstalling diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..c66526f --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# commit-msg hook and CI validator for Nightshift +# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg) +set -euo pipefail + +readonly ALLOWED_TYPES='build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test' +readonly CONVENTIONAL_PATTERN="^(${ALLOWED_TYPES})(\\([a-z0-9][a-z0-9._/-]*\\))?(!)?: [^ ].*$" +readonly MERGE_PATTERN='^Merge ' +readonly REVERT_PATTERN='^Revert ".+"$' +readonly VERSION_BUMP_PATTERN='^Bump version to v[0-9]+(\.[0-9]+)+([.-][0-9A-Za-z.-]+)?$' +readonly RELEASE_PATTERN='^Release v[0-9]+(\.[0-9]+)+([.-][0-9A-Za-z.-]+)?(: .+)?$' + +usage() { + cat <<'EOF' +Usage: + scripts/commit-msg.sh + scripts/commit-msg.sh --rev-range + +Expected subject format: + type(scope?): summary + +Allowed types: + build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test +EOF +} + +print_error() { + local source="$1" + local subject="$2" + + { + echo "Invalid commit subject in ${source}:" + echo " ${subject}" + echo "" + echo "Expected first line to match:" + echo " type(scope?): summary" + echo "" + echo "Allowed types:" + echo " build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" + echo "" + echo "Examples:" + echo " feat(cli): add commit message validator" + echo " fix: handle pull request commit ranges in CI" + echo " docs(readme): document local hook installation" + echo "" + echo "Allowed exceptions:" + echo " Merge pull request #123 from owner/branch" + echo " Revert \"feat(cli): add commit message validator\"" + echo " Bump version to v0.3.4" + echo " Release v0.3.4: changelog and binaries" + } >&2 +} + +is_valid_subject() { + local subject="$1" + + [[ "$subject" =~ $CONVENTIONAL_PATTERN ]] && return 0 + [[ "$subject" =~ $MERGE_PATTERN ]] && return 0 + [[ "$subject" =~ $REVERT_PATTERN ]] && return 0 + [[ "$subject" =~ $VERSION_BUMP_PATTERN ]] && return 0 + [[ "$subject" =~ $RELEASE_PATTERN ]] && return 0 + return 1 +} + +validate_subject() { + local source="$1" + local subject="$2" + + if ! is_valid_subject "$subject"; then + print_error "$source" "$subject" + return 1 + fi +} + +validate_message_file() { + local message_file="$1" + local subject + + if [[ ! -f "$message_file" ]]; then + echo "Commit message file not found: $message_file" >&2 + return 1 + fi + + subject=$(sed -n '1{s/[[:space:]]*$//;p;}' "$message_file") + validate_subject "$message_file" "$subject" +} + +validate_rev_range() { + local rev_spec="$1" + local commits=() + local rev + local subject + + if [[ "$rev_spec" == *..* ]]; then + while IFS= read -r rev; do + commits+=("$rev") + done < <(git rev-list --reverse "$rev_spec") + else + commits=("$rev_spec") + fi + + if [[ ${#commits[@]} -eq 0 ]]; then + echo "No commits found for revision range: $rev_spec" >&2 + return 1 + fi + + for rev in "${commits[@]}"; do + subject=$(git log -1 --format=%s "$rev") + validate_subject "$rev" "$subject" + printf '✓ %s %s\n' "${rev:0:7}" "$subject" + done +} + +main() { + case "${1-}" in + "") + usage >&2 + exit 1 + ;; + -h|--help) + usage + exit 0 + ;; + --rev-range) + if [[ $# -ne 2 ]]; then + usage >&2 + exit 1 + fi + validate_rev_range "$2" + ;; + *) + if [[ $# -ne 1 ]]; then + usage >&2 + exit 1 + fi + validate_message_file "$1" + ;; + esac +} + +main "$@"