Skip to content
Open
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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ 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
range="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
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
Expand Down
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,14 @@ 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:
@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 "✓ hooks installed"
@echo " .git/hooks/pre-commit -> scripts/pre-commit.sh"
@echo " .git/hooks/commit-msg -> scripts/commit-msg.sh"
50 changes: 43 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,20 +258,56 @@ 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:
- 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 symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit` and
`scripts/commit-msg.sh` into `.git/hooks/commit-msg`.

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

Expand Down
65 changes: 65 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# commit-msg hook for nightshift
# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg)
set -euo pipefail

TYPES="build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test"

usage() {
echo "usage: $0 <commit-msg-file>" >&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 '
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 [[ "$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
Loading