From 48069dac19353f9ca9143fb17b48001cb5927f62 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Wed, 1 Apr 2026 03:23:38 -0700 Subject: [PATCH 1/2] chore: harden commit message validation Tighten merge exemptions, validate PR commit ranges from merge-base, and install hooks through Git's resolved hooks directory. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++ Makefile | 14 ++++++--- README.md | 52 +++++++++++++++++++++++++++----- scripts/commit-msg.sh | 65 ++++++++++++++++++++++++++++++++++++++++ scripts/pre-commit.sh | 2 +- 5 files changed, 165 insertions(+), 12 deletions(-) create mode 100755 scripts/commit-msg.sh 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..65ef875 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,16 @@ 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 - @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @hooks_dir="$$(git rev-parse --git-path hooks)"; \ + repo_root="$$(git rev-parse --show-toplevel)"; \ + mkdir -p "$$hooks_dir"; \ + ln -sf "$$repo_root/scripts/pre-commit.sh" "$$hooks_dir/pre-commit"; \ + ln -sf "$$repo_root/scripts/commit-msg.sh" "$$hooks_dir/commit-msg"; \ + echo "✓ hooks installed"; \ + echo " $$hooks_dir/pre-commit -> $$repo_root/scripts/pre-commit.sh"; \ + echo " $$hooks_dir/commit-msg -> $$repo_root/scripts/commit-msg.sh" diff --git a/README.md b/README.md index 84f92cd..b8495e5 100644 --- a/README.md +++ b/README.md @@ -258,20 +258,58 @@ 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: +- Actual 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 installs both local hooks using Git's configured hooks directory, including +worktree setups: +- `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..481d502 --- /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, 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 (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]+)*([[:space:]]+\(#[0-9]+\))?$' +release_re='^Release v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*(: .+)?([[:space:]]+\(#[0-9]+\))?$' + +if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1 || [[ "$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 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 From fee0380b8258e78d96e0596e5c264b5160cab71b Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Wed, 1 Apr 2026 03:41:35 -0700 Subject: [PATCH 2/2] fix: harden commit hook edge cases Accept GitHub revert suffixes, replace symlink installs with runtime-resolved wrappers, and safely overwrite legacy hook links during install. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 24 ++++++++++++++++-------- README.md | 8 +++++--- scripts/commit-msg.sh | 16 ++++++++-------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 65ef875..73b5464 100644 --- a/Makefile +++ b/Makefile @@ -75,16 +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 and commit-msg hooks" + @echo " install-hooks - Install git pre-commit and commit-msg hook wrappers" @echo " help - Show this help" -# Install git hooks +# Install git hook wrappers install-hooks: @hooks_dir="$$(git rev-parse --git-path hooks)"; \ - repo_root="$$(git rev-parse --show-toplevel)"; \ mkdir -p "$$hooks_dir"; \ - ln -sf "$$repo_root/scripts/pre-commit.sh" "$$hooks_dir/pre-commit"; \ - ln -sf "$$repo_root/scripts/commit-msg.sh" "$$hooks_dir/commit-msg"; \ - echo "✓ hooks installed"; \ - echo " $$hooks_dir/pre-commit -> $$repo_root/scripts/pre-commit.sh"; \ - echo " $$hooks_dir/commit-msg -> $$repo_root/scripts/commit-msg.sh" + 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 b8495e5..26270e2 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ 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. +is accepted, including `Revert "..." (#123)` subjects from revert PRs. Valid examples: @@ -287,6 +287,7 @@ 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 @@ -297,8 +298,9 @@ Install the local git hooks before contributing: make install-hooks ``` -This installs both local hooks using Git's configured hooks directory, including -worktree setups: +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` diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh index 481d502..fc38cb9 100755 --- a/scripts/commit-msg.sh +++ b/scripts/commit-msg.sh @@ -14,7 +14,7 @@ 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 + echo 'exceptions: merge commits, Revert "...", Bump version..., Release v...' >&2 exit 1 } @@ -41,23 +41,23 @@ 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]+)*([[:space:]]+\(#[0-9]+\))?$' -release_re='^Release v[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)*(: .+)?([[:space:]]+\(#[0-9]+\))?$' +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" =~ $revert_re ]] || [[ "$subject" =~ $release_bump_re ]] || [[ "$subject" =~ $release_re ]]; then +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="$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