diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc5686..64e4048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,50 @@ 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 + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + merge_base="$(git merge-base "$base" "$head")" + range="$merge_base..$head" + 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..73b5464 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,24 @@ 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 hook wrappers" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hook wrappers install-hooks: - @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @hooks_dir="$$(git rev-parse --git-path hooks)"; \ + mkdir -p "$$hooks_dir"; \ + for hook in pre-commit commit-msg; do \ + hook_path="$$hooks_dir/$$hook"; \ + script_path="scripts/$$hook.sh"; \ + rm -f "$$hook_path"; \ + { \ + printf '%s\n' '#!/usr/bin/env bash'; \ + printf '%s\n' 'set -euo pipefail'; \ + printf '%s\n' 'repo_root="$$(git rev-parse --show-toplevel)"'; \ + printf '%s\n' 'exec "$$repo_root/'"$$script_path"'" "$$@"'; \ + } > "$$hook_path"; \ + chmod +x "$$hook_path"; \ + echo "✓ $$hook_path -> $$script_path"; \ + done; \ + echo "hooks installed with worktree-safe wrappers" diff --git a/README.md b/README.md index 84f92cd..26270e2 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 +### 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, including `Revert "..." (#123)` subjects from revert PRs. + +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: +- Actual merge commits generated by Git +- `Revert "..."` commits generated by `git revert` +- 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 writes both hooks into Git's configured hooks directory as lightweight, +worktree-safe wrappers. Each wrapper resolves the current worktree root at +runtime, then runs: +- `scripts/pre-commit.sh` +- `scripts/commit-msg.sh` + +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..fc38cb9 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# Install: make install-hooks +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, Revert "...", 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]}" +subject_core="$subject" +if [[ "$subject_core" =~ ^(.+)\ \(#[0-9]+\)$ ]]; then + subject_core="${BASH_REMATCH[1]}" +fi + +merge_re='^Merge (branch|remote-tracking branch|pull request|tag) ' +revert_re='^Revert ".*"$' +release_bump_re='^Bump version to v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*$' +release_re='^Release v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*(: .+)?$' + +if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1 || [[ "$subject" =~ $merge_re ]] || [[ "$subject_core" =~ $revert_re ]] || [[ "$subject_core" =~ $release_bump_re ]] || [[ "$subject_core" =~ $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" != *. ]] || 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 diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index c597d65..975f717 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # pre-commit hook for nightshift -# Install: make install-hooks (or: ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit) +# Install: make install-hooks set -euo pipefail PASS=0