Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
54 changes: 47 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
141 changes: 141 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -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 <commit-message-file>
scripts/commit-msg.sh --rev-range <git-revision-or-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 "$@"