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
9 changes: 9 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
#
# Normalize the commit message in place, then validate it. Fixable problems are
# corrected silently; only unfixable ones reject the commit.
set -euo pipefail

repo_root=$(git rev-parse --show-toplevel)
"$repo_root/scripts/normalize-commit-msg.sh" "$1"
exec "$repo_root/scripts/validate-commit-msg.sh" "$1"
5 changes: 5 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

repo_root=$(git rev-parse --show-toplevel)
exec "$repo_root/scripts/pre-commit.sh" "$@"
36 changes: 36 additions & 0 deletions .github/workflows/commit-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Commit Lint

on:
pull_request:

jobs:
commit-lint:
name: Commit messages
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run commit-message test suite
run: ./tests/commit-msg-test.sh

- name: Validate commit subjects added by this PR
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
status=0
while IFS= read -r subject; do
[ -n "$subject" ] || continue
if ! ./scripts/validate-commit-msg.sh --subject "$subject"; then
status=1
fi
done < <(git log --no-merges --pretty=%s "$BASE_SHA..$HEAD_SHA")
if [ "$status" -ne 0 ]; then
echo "::error::One or more commit subjects do not follow docs/commit-conventions.md"
exit 1
fi
echo "All commit subjects follow docs/commit-conventions.md"
16 changes: 10 additions & 6 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-commit-msg test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help

# Binary name
BINARY=nightshift
Expand All @@ -18,9 +18,13 @@ calibrate-providers:
go run ./cmd/provider-calibration --repo "$$(pwd)" --codex-originator codex_cli_rs --min-user-turns 2

# Run all tests
test:
test: test-commit-msg
go test ./...

# Run commit-message normalizer/validator regression tests
test-commit-msg:
./tests/commit-msg-test.sh

# Run tests with verbose output
test-verbose:
go test -v ./...
Expand Down Expand Up @@ -65,6 +69,7 @@ help:
@echo "Available targets:"
@echo " build - Build the binary"
@echo " test - Run all tests"
@echo " test-commit-msg - Run commit-message normalizer/validator tests"
@echo " test-verbose - Run tests with verbose output"
@echo " test-race - Run tests with race detection"
@echo " coverage - Run tests with coverage report"
Expand All @@ -75,10 +80,9 @@ 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 - Enable repository-managed git hooks"
@echo " help - Show this help"

# Install git pre-commit hook
# Enable repository-managed 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)"
@./scripts/install-hooks.sh
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,63 @@ Each task has a default cooldown interval to prevent the same task from running

## Development

### Pre-commit hooks
### Git hooks and commit messages

Install the git pre-commit hook to catch formatting and vet issues before pushing:
Hook installation is opt-in. Enable the repository-managed hooks with:

```bash
make install-hooks
# Equivalent command:
git config core.hooksPath .githooks
```

This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs:
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 message in place and then validates it
against [Conventional Commits](docs/commit-conventions.md):

```text
type(scope)!: summary
```

Supported types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`,
`refactor`, `revert`, `style`, and `test`. The scope and the `!` breaking-change
marker are optional. Subjects are limited to 72 characters, use the imperative
mood, start lowercase, and carry no trailing period.

Normalization only rewrites what is mechanically safe — whitespace, the casing
of a recognized type, a trailing period, and the blank line between subject and
body. It never invents a type unless you explicitly run
`scripts/normalize-commit-msg.sh --infer`. Anything it cannot fix is reported by
`scripts/validate-commit-msg.sh` with one line per violated rule.

Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash
subjects, and comment-only templates are never rewritten or rejected. Comment
lines are recognized using your configured `core.commentString` or
`core.commentChar`, defaulting to `#`. Everything from git's scissors marker
(`git commit -v`) onward is left byte-for-byte untouched.

Examples:

```text
feat(run): add pause command
fix(config)!: reject unknown provider keys
docs: explain hook installation
```

Run the shell regression suite directly with:

```bash
make test-commit-msg
```

CI enforces the same rules on every commit a pull request adds, so contributors
who skip the hooks still get the check. See
[docs/commit-conventions.md](docs/commit-conventions.md) for the full reference.

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

## Uninstalling
Expand Down
111 changes: 111 additions & 0 deletions docs/commit-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Commit Conventions

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/).
The format is enforced locally by an opt-in `commit-msg` hook and in CI for every
commit a pull request adds.

## Format

```text
type(scope)!: summary

Optional body, wrapped at 72 columns.

Optional-Trailer: value
```

- **type** — required, lowercase, one of:
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`,
`style`, `test`
- **scope** — optional, in parentheses. Letters, digits, and `_ . / # -`
(e.g. `run`, `config`, `.github/workflows`, `#19`).
- **!** — optional, marks a breaking change. Pair it with a
`BREAKING CHANGE:` footer explaining the migration.
- **summary** — required, imperative mood, starts lowercase (acronyms such as
`API` keep their case), no trailing period.
- **subject line** — 72 characters or fewer, including the type and scope.
GitHub appends ` (#123)` when squash-merging, so leaving headroom helps.
- **body** — optional, separated from the subject by exactly one blank line,
wrapped at 72 columns.
- **footers** — `BREAKING CHANGE: …` and issue references (`Fixes #21`) go last.

### Examples

```text
feat(run): add pause command
fix(config)!: reject unknown provider keys
docs: explain hook installation
ci(.github/workflows): pin action versions
```

## Messages that are never rewritten or rejected

Git writes some subjects itself. Blocking those would break ordinary workflows,
so both the normalizer and the validator pass them through untouched:

- `Merge …` and `Revert "…"`
- `fixup!`, `squash!`, and `amend!` (autosquash)
- `WIP on …` and `index on …` (stashes)
- comment-only messages produced by the commit template (comment lines are
detected using `core.commentString` / `core.commentChar`, defaulting to `#`)

Under `git commit -v` (or `commit.verbose = true`) git appends a scissors
marker followed by a raw diff:

```text
# ------------------------ >8 ------------------------
diff --git a/main.go b/main.go
```

Git truncates the message at that marker. The scissors line and everything
below it are copied through byte for byte — nothing is moved above the marker
and nothing below it is validated, so the diff never leaks into the commit
body.

## Tooling

| Command | Purpose |
| --- | --- |
| `scripts/normalize-commit-msg.sh <file>` | Rewrites a message file in place |
| `scripts/normalize-commit-msg.sh --infer <file>` | Also prefixes `chore: ` when no type is present |
| `scripts/validate-commit-msg.sh <file>` | Validates a message file |
| `scripts/validate-commit-msg.sh --subject "<text>"` | Validates a single subject line |
| `scripts/install-hooks.sh` (or `make install-hooks`) | Enables the repository hooks |
| `make test-commit-msg` | Runs `tests/commit-msg-test.sh` |

### What the normalizer changes

Normalization is deliberately conservative — it only rewrites what is
mechanically safe:

- trims trailing whitespace and leading/trailing blank lines
- collapses repeated whitespace inside the subject
- lowercases a recognized type and removes spacing around the scope and `!`
- removes a single trailing period from the subject (`...` is preserved)
- lowercases a capitalized first word (`Add x` → `add x`), leaving acronyms and
mixed-case identifiers alone
- inserts the missing blank line between subject and body, and collapses runs of
blank lines in the body

It never invents a type. If a subject has no recognized `type:` prefix, the
normalizer leaves it for the validator to report — unless you explicitly pass
`--infer`, which prefixes `chore: `.

## Local setup

```bash
make install-hooks # git config core.hooksPath .githooks
```

This is opt-in; contributors who skip it are only checked in CI. The
`commit-msg` hook normalizes the message in place first, so most problems are
fixed silently and only genuinely ambiguous ones reject the commit. Bypass a
single commit with `git commit --no-verify`, and disable the hooks entirely with
`git config --unset core.hooksPath`.

## CI

`.github/workflows/commit-lint.yml` runs the shell test suite and validates the
subject of every commit the pull request adds
(`git log --pretty=%s origin/<base>..HEAD`). Commits already on the base branch
are not re-checked, so the pre-Conventional-Commits history stays as it is.
Loading