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
45 changes: 45 additions & 0 deletions .github/workflows/commit-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Commit Lint

on:
pull_request:
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 in this pull request
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
failed=0
commits=$(git rev-list "$BASE_SHA".."$HEAD_SHA")
if [ -z "$commits" ]; then
echo "No commits to check."
exit 0
fi
for sha in $commits; do
if git log -1 --format=%B "$sha" | scripts/validate-commit-msg.sh -; then
echo "✓ $(git log -1 --format='%h %s' "$sha")"
else
echo " ↳ in commit $(git log -1 --format='%h' "$sha")"
failed=$((failed + 1))
fi
done
echo ""
if [ "$failed" -gt 0 ]; then
echo "❌ $failed commit message(s) do not follow docs/commit-messages.md"
exit 1
fi
echo "✅ all commit messages conform"

- name: Run the commit message hook tests
run: make test-commit-msg
56 changes: 56 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Contributing to Nightshift

Thanks for helping out. This file covers the mechanics; see
[README.md](README.md) for what Nightshift is and how to run it.

## Getting set up

```sh
go build ./...
make test # go test ./...
make install-hooks # opt-in git hooks (pre-commit + commit-msg)
```

`make install-hooks` symlinks the hooks in `scripts/` into `.git/hooks`. It is
explicit on purpose — nothing installs hooks or edits your git config for you.
Remove them with `make uninstall-hooks`.

## Commit messages

Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):

```
type(scope)!: description
```

- allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`,
`refactor`, `revert`, `style`, `test`
- imperative mood, no trailing period, 72 characters or fewer
- a body, if present, is separated from the subject by a blank line
- merge, revert, fixup and squash commits are exempt

The full standard, including what the normalizer will and will not rewrite and
when bypassing the hook is legitimate, is in
[docs/commit-messages.md](docs/commit-messages.md).

The `commit-msg` hook fixes safe deviations (trailing period, capitalized type
or leading verb, spacing) in place and rejects anything it cannot fix. The
**Commit Lint** CI job checks every commit in a pull request, so `--no-verify`
defers the problem rather than avoiding it.

Existing history is not rewritten; the standard applies to new commits.

## Before opening a pull request

```sh
make check # go test ./... + commit message tests + golangci-lint
```

`make test-commit-msg` runs the commit message hook tests on their own. They are
dependency-free POSIX shell and run under `dash`, `bash`, and `zsh`.

## Pull requests

- keep the change focused; one concern per pull request
- update `README.md` and `docs/` when behaviour or configuration changes
- note anything intentionally left out of scope in the pull request description
21 changes: 17 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help
.PHONY: build test test-verbose test-race test-commit-msg coverage coverage-html lint clean deps check install calibrate-providers install-hooks uninstall-hooks help

# Binary name
BINARY=nightshift
Expand Down Expand Up @@ -29,6 +29,10 @@ test-verbose:
test-race:
go test -race ./...

# Run the commit message normalizer/validator shell tests
test-commit-msg:
@sh scripts/tests/commit-msg.test.sh

# Run tests with coverage report
coverage:
go test -coverprofile=coverage.out ./...
Expand Down Expand Up @@ -58,7 +62,7 @@ deps:
go mod tidy

# Run all checks (test + lint)
check: test lint
check: test test-commit-msg lint

# Show help
help:
Expand All @@ -67,6 +71,7 @@ help:
@echo " test - Run all tests"
@echo " test-verbose - Run tests with verbose output"
@echo " test-race - Run tests with race detection"
@echo " test-commit-msg - Run the commit message hook tests"
@echo " coverage - Run tests with coverage report"
@echo " coverage-html - Generate HTML coverage report"
@echo " lint - Run golangci-lint"
Expand All @@ -75,10 +80,18 @@ 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 " uninstall-hooks - Remove the hooks installed by install-hooks"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git hooks. Opt-in: nothing installs these for you.
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)"
@ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
@echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)"

# Remove the hooks installed by install-hooks
uninstall-hooks:
@rm -f .git/hooks/pre-commit .git/hooks/commit-msg
@echo "✓ pre-commit and commit-msg hooks removed"
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,34 @@ 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 the git hooks to catch formatting, vet, and commit message issues before pushing:

```bash
make install-hooks
make install-hooks # install
make uninstall-hooks # remove
```

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

The commit-msg hook normalizes the commit message toward
[Conventional Commits](docs/commit-messages.md) and rejects what it cannot
safely fix. The same rules are checked in CI for every commit in a pull request.

To bypass in a pinch: `git commit --no-verify`

### Commit messages

Commit subjects follow `type(scope): description` — see
[docs/commit-messages.md](docs/commit-messages.md) and [CONTRIBUTING.md](CONTRIBUTING.md).

## Uninstalling

```bash
Expand Down
144 changes: 144 additions & 0 deletions docs/commit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Commit Messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
for commit subjects. The format is machine-parseable, which keeps the door open
for generated changelogs and release notes, and it is already what most of this
repository's history looks like.

Two things enforce it, and both are opt-in or advisory rather than magic:

- a local `commit-msg` hook that normalizes what it safely can and rejects what
it cannot (`make install-hooks`)
- a CI job that validates every commit in a pull request
(`.github/workflows/commit-lint.yml`)

Existing history is **not** rewritten. The standard applies going forward.

## The format

```
type(scope)!: description

Optional body, wrapped at 72 columns.

Trailer-Key: value
```

Rules:

| Rule | Detail |
|------|--------|
| Type | One of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. Lowercase. |
| Scope | Optional, in parentheses, lowercase: `feat(runner)`, `fix(config)`. |
| Breaking | Optional `!` before the colon: `feat(config)!: drop the legacy schema`. |
| Separator | Exactly one space after the colon. |
| Description | Imperative mood ("add", not "added" or "adds"). No trailing period. |
| Subject length | 72 characters or fewer, including the type prefix. A trailing ` (#123)` that GitHub appends on squash merge is not counted. |
| Body | Optional. Separated from the subject by one blank line. |
| Trailers | Last, one per line: `Co-Authored-By:`, `Nightshift-Task:`, and so on. |

The allowed type list is derived from the types this repository's own history
already uses (`feat`, `fix`, `docs`, `chore`, `test`, `refactor`) plus the rest
of the standard Conventional Commits set, so the vocabulary is not artificially
narrow.

### Exempt commits

Commits git generates or rewrites itself are never normalized and never
rejected:

- `Merge ...`
- `Revert "..."`
- `fixup! ...`, `squash! ...`, `amend! ...`

## Examples

Good:

```
feat(runner): add a per-provider retry budget
fix: guard against a nil provider map
docs: document the commit message standard
refactor(config)!: drop the v1 schema loader
chore: bump golangci-lint to v1.62
```

Rejected, and why:

| Subject | Problem |
|---------|---------|
| `Update the makefile` | No type prefix. |
| `chores: tidy the makefile` | `chores` is not an allowed type. |
| `Fix: guard against a nil provider` | Type must be lowercase (the hook fixes this for you). |
| `fix:guard against a nil provider` | Missing space after the colon (the hook fixes this for you). |
| `fix: guard against a nil provider.` | Trailing period (the hook fixes this for you). |
| `feat: <73+ characters>` | Subject exceeds 72 characters. |
| subject immediately followed by body | Missing blank line after the subject. |

## What the normalizer will and will not do

`scripts/normalize-commit-msg.sh` is deliberately conservative. It applies only
mechanical fixes it can make with certainty:

- strips trailing whitespace from the subject
- strips trailing periods from the subject
- lowercases a recognized type token (`Fix:` → `fix:`)
- inserts the missing space after the colon (`fix:add x` → `fix: add x`)
- lowercases the first word of the description **only** when that word is a
known imperative verb (`fix: Add x` → `fix: add x`)
- ensures exactly one blank line between the subject and the body

It will not:

- touch the body, interior blank lines, or trailers — those are copied verbatim
- guess a type for a subject that has none
- lowercase a leading word it does not recognize, so `docs: Nightshift now …`
keeps its proper noun
- edit merge, revert, fixup or squash commits

When it cannot parse a subject confidently it leaves the message alone and lets
`scripts/validate-commit-msg.sh` explain the problem. The normalizer can never
turn a valid message into an invalid one.

## Installing the hook

Hook installation is explicit. Nothing changes your git configuration on clone,
build, or test.

```sh
make install-hooks # installs .git/hooks/pre-commit and .git/hooks/commit-msg
make uninstall-hooks # removes both
```

Both hooks are symlinks into `scripts/`, so they track the checked-out branch.

## Bypassing the hook

```sh
git commit --no-verify -m "…"
```

Legitimate reasons to bypass:

- you are mid-rebase or scripting a mechanical history operation
- a vendored or generated commit message must be preserved byte-for-byte
- the hook itself is broken and you are committing the fix

Bypassing the local hook does not bypass CI. The Commit Lint job validates every
commit in a pull request, so a bypassed commit still has to be reworded (with
`git commit --amend` or an interactive rebase) before the pull request is
mergeable.

## Files

| Path | Purpose |
|------|---------|
| `scripts/commit-msg-lib.sh` | Shared type list, limits, and helpers. |
| `scripts/normalize-commit-msg.sh` | In-place safe normalization of a message file. |
| `scripts/validate-commit-msg.sh` | Validation; accepts a file path or `-` for stdin. |
| `scripts/commit-msg.sh` | The hook: normalize, then validate. |
| `scripts/tests/commit-msg.test.sh` | Shell tests (`make test-commit-msg`). |
| `.github/workflows/commit-lint.yml` | CI validation of every commit in a PR. |

Everything is dependency-free POSIX shell — no Node, no commitlint, no husky.
The tests run under `dash`, `bash`, and `zsh`.
Loading