diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5686..150b09c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: branches: [main] jobs: + commit-format: + name: Commit Format + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Validate pull request title + run: bash ./scripts/commit-msg.sh --title "${{ github.event.pull_request.title }}" + test: name: Test runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 088be01..77529ca 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,13 @@ 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 pre-commit and commit-msg 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 "✓ 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..6eb1e2d 100644 --- a/README.md +++ b/README.md @@ -258,20 +258,47 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git hooks and commit messages -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Install the local git hooks 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 -To bypass in a pinch: `git commit --no-verify` +The `commit-msg` hook validates the first non-comment line of each commit message. Use Conventional Commits: +- `type: summary` +- `type(scope): summary` + +Accepted types: +- `build` +- `chore` +- `ci` +- `docs` +- `feat` +- `fix` +- `perf` +- `refactor` +- `style` +- `test` + +Examples: +- `feat(run): add pause command` +- `fix(config): preserve provider YAML keys` +- `docs(readme): explain hook installation` + +Git-generated `Merge ...` and `Revert ...` subjects are allowed automatically. + +Pull request titles are validated in CI with the same script so squash-merge commits on `main` stay consistent even when local hooks are skipped. + +To bypass 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..6963709 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# Install: make install-hooks +set -euo pipefail + +readonly CONVENTIONAL_TYPES='build|chore|ci|docs|feat|fix|perf|refactor|style|test' +readonly CONVENTIONAL_PATTERN="^(${CONVENTIONAL_TYPES})(\\([[:alnum:]./_-]+\\))?: [^[:space:]].*" + +usage() { + echo "Usage: scripts/commit-msg.sh | scripts/commit-msg.sh --title \"\"" >&2 +} + +read_subject() { + if [[ $# -eq 2 && "$1" == "--title" ]]; then + awk ' + { + line = $0 + sub(/\r$/, "", line) + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line != "" && line !~ /^#/) { + print line + exit + } + } + ' <<<"$2" + return + fi + + if [[ $# -eq 1 ]]; then + awk ' + { + line = $0 + sub(/\r$/, "", line) + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line != "" && line !~ /^#/) { + print line + exit + } + } + ' "$1" + return + fi + + usage + exit 2 +} + +print_failure() { + cat >&2 <<'EOF' +Commit subject must use Conventional Commits: + type: summary + type(scope): summary + +Accepted types: build, chore, ci, docs, feat, fix, perf, refactor, style, test +Allowed exceptions: Merge ..., Revert ... + +Examples: + feat(run): add pause command + fix(config): preserve provider YAML keys + docs(readme): explain hook installation +EOF +} + +if [[ $# -eq 1 && ( "$1" == "-h" || "$1" == "--help" ) ]]; then + usage + exit 0 +fi + +subject="$(read_subject "$@")" + +if [[ -z "$subject" ]]; then + echo "Commit subject is empty." >&2 + print_failure + exit 1 +fi + +if [[ "$subject" =~ ^(Merge|Revert)\ ]]; then + exit 0 +fi + +if [[ "$subject" =~ $CONVENTIONAL_PATTERN ]]; then + exit 0 +fi + +echo "Invalid commit subject: $subject" >&2 +print_failure +exit 1