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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,41 @@ jobs:
uses: golangci/golangci-lint-action@v6
with:
version: latest

commit-lint:
name: Commit messages
runs-on: ubuntu-latest
# PR-range only: history on main predates this convention and is grandfathered.
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Test the validator
run: bash scripts/commit-msg-test.sh

- name: Validate commit messages in this PR
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags origin "$BASE_REF"
RANGE="origin/$BASE_REF..HEAD"
FAIL=0
COUNT=0
for sha in $(git rev-list "$RANGE"); do
COUNT=$((COUNT+1))
git log -1 --format=%B "$sha" > /tmp/commit-msg
if ! bash scripts/commit-msg.sh /tmp/commit-msg; then
echo " ↳ commit $(git log -1 --format='%h %s' "$sha")"
FAIL=$((FAIL+1))
fi
done
echo ""
if [ "$FAIL" -gt 0 ]; then
echo "❌ $FAIL of $COUNT commit message(s) in $RANGE are invalid."
echo " See CONTRIBUTING.md, or run: make install-hooks"
exit 1
fi
echo "✅ All $COUNT commit message(s) in $RANGE are valid."
34 changes: 34 additions & 0 deletions .gitmessage
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@


# ── Nightshift commit message ────────────────────────────────────────────────
#
# Subject (line 1): <type>[(<scope>)][!]: <description>
#
# type one of: feat fix docs chore test refactor perf build ci style
# revert
# scope optional, lowercase: config tasks providers budget scheduler
# orchestrator daemon web docs ci
# ! optional, marks a breaking change (also add the footer below)
# description imperative mood, not sentence-cased, no trailing period
# (leading digits and acronyms are fine: "2x faster lookups",
# "HTTP retry support" — but not "Add retry support")
#
# Hard limits: subject ≤ 72 chars, body wrapped at 100 chars.
# Line 2 must be blank when a body follows.
#
# Body (optional): what changed and why, not how. Wrap at 100 chars.
#
# Footers (optional):
# BREAKING CHANGE: <what broke and how to migrate>
# Fixes #123
# Nightshift-Task: <task-id>
# Nightshift-Ref: https://github.com/marcus/nightshift
#
# Examples:
# feat(budget): add codex daily token calibration
# fix(config): guard nil provider map on merge
# docs: document the commit message convention
# feat(api)!: drop legacy budget fields
#
# The commit-msg hook enforces the subject rules. Bypass with --no-verify.
# ─────────────────────────────────────────────────────────────────────────────
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,9 @@ go test ./...
- **Style**: Standard Go (gofmt, govet). No magic, explicit is better.
- **Errors**: Wrap with context, don't swallow.
- **Tests**: Table-driven, in `_test.go` files alongside code.
- **Commit messages**: Conventional Commits - `<type>[(<scope>)][!]: <description>`.
Types: feat, fix, docs, chore, test, refactor, perf, build, ci, style, revert.
Subject imperative, not sentence-cased, no trailing period, <= 72 chars; blank line before
any body. Agent-authored commits must carry `Nightshift-Task:` and
`Nightshift-Ref:` trailers. Enforced by `scripts/commit-msg.sh`; see
[CONTRIBUTING.md](CONTRIBUTING.md#commit-messages).
165 changes: 165 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Contributing to Nightshift

Thanks for helping out. This document covers the local setup and the
conventions the project enforces.

## Setup

```bash
git clone https://github.com/marcus/nightshift.git
cd nightshift
make deps
make install-hooks # one-command setup: git hooks + commit template
make build
make check # go test + golangci-lint + commit-msg validator tests
```

`make install-hooks` wires up three things:

| What | Where | Purpose |
|---|---|---|
| `scripts/pre-commit.sh` | `.git/hooks/pre-commit` | gofmt, `go vet`, `go build` on staged Go files |
| `scripts/commit-msg.sh` | `.git/hooks/commit-msg` | validates the commit subject (see below) |
| `.gitmessage` | `git config commit.template` | prefills `git commit` with the format guide |

Hooks are opt-in and local — they are never installed automatically. Bypass
either hook in a pinch with `git commit --no-verify`.

## Commit messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/).
This is not a new invention: it is the format the repository already used for
the majority of its history, now written down and enforced going forward.

### Format

```
<type>[(<scope>)][!]: <description>

[optional body, wrapped at 100 chars]

[optional footers]
```

Subject rules, all enforced by `scripts/commit-msg.sh`:

- `<type>` is required and must be one of the types below.
- `<scope>` is optional, in parentheses, lowercase `[a-z0-9._/-]`.
- `!` before the colon marks a breaking change.
- Exactly one space after the colon.
- `<description>` is imperative mood ("add", not "added"/"adds"), is not
sentence-cased, and does not end with a period. It must start with a letter
or digit; leading digits and acronyms are fine (`2x faster lookups`,
`HTTP retry support`, `OAuth token refresh`). What the hook rejects is a
capital immediately followed by a lowercase letter — `Add budget calibration`.
- The whole subject line is **72 characters or fewer**.
- If a body follows, line 2 must be blank.

### Types

| Type | Use for |
|---|---|
| `feat` | a new user-visible capability |
| `fix` | a bug fix |
| `docs` | documentation only, including `website/` |
| `refactor` | code change that neither fixes a bug nor adds a feature |
| `perf` | a change made to improve performance |
| `test` | adding or correcting tests |
| `build` | build system, `go.mod`, goreleaser, Makefile |
| `ci` | GitHub Actions and other CI configuration |
| `chore` | maintenance that fits nothing else (version bumps, tidying) |
| `style` | formatting only, no behaviour change |
| `revert` | reverting an earlier commit |

### Scopes

Scopes are optional but encouraged. Use the package or area the change lands
in — the names that already appear in the tree:

`config`, `budget`, `scheduler`, `providers`, `tasks`, `orchestrator`,
`commands`, `daemon`, `web`, `docs`, `hooks`, `ci`

### Breaking changes

Mark them both ways — `!` in the subject for scanability, and a
`BREAKING CHANGE:` footer explaining the migration:

```
feat(config)!: drop v1 provider keys

BREAKING CHANGE: `providers.claude.path` is now `providers.claude.data_path`.
Run `nightshift config validate` after upgrading.
```

### Agent-authored commits

Commits produced by a Nightshift agent run must carry these trailers in the
footer so the run can be traced back:

```
Nightshift-Task: <task-id>
Nightshift-Ref: https://github.com/marcus/nightshift
```

### Examples

```
feat(budget): add codex daily token calibration
fix(config): guard nil provider map on merge
docs: document the commit message convention
ci: lint pull request commit messages
feat(api)!: drop legacy budget fields
```

Commits git itself writes or rewrites are exempt from validation: merge
commits, `Revert "..."`, and `fixup!` / `squash!` / `amend!` commits.

### Grandfathered history

History is **not** rewritten. Of the 171 commits on `main` at the time this
convention was written down, 129 (75%) already used a Conventional Commits
prefix and 110 pass the validator as-is; the remaining 61 predate the rules —
mostly merge commits, `Bump version to ...` subjects, and otherwise-valid
subjects that run past 72 characters because a `(#42)` or `(td-abc123)` ref was
appended. Those stay as they are.

To reproduce those numbers:

```bash
git rev-list --count main # 171
git log --pretty=%s main | grep -cE \
'^(feat|fix|docs|chore|test|refactor|perf|build|ci|style|revert)(\([^)]+\))?!?: ' # 129
git log --pretty=%s main | while read -r s; do \
printf '%s\n' "$s" > /tmp/m && scripts/commit-msg.sh /tmp/m >/dev/null 2>&1 \
|| echo "$s"; done | wc -l # 61 rejected -> 110 pass
```

The 129 figure counts *any* parenthesised scope. Exactly one of those commits
(`fix(#19): ...`) uses a scope the validator rejects, so a stricter count that
requires a `[a-z0-9._/-]+` scope gives 128. Either way the 110-pass and
61-reject figures are unchanged, since the validator is what produced them.

Because of this, CI validates **only the commits in a pull request's range**
(`origin/<base>..HEAD`), never the full history. A `git log` on `main` will
still show non-conforming subjects, and that is expected.

Note that GitHub appends ` (#N)` to the subject on squash merge. That happens
server-side, after the hook has run, so a subject that is legal locally can end
up slightly over 72 characters on `main`. Leave a little headroom.

## Pull requests

- PR titles follow the same format as commit subjects — the squash-merge
subject comes from the PR title.
- Describe *why* in the body, not just what.
- `make check` must pass locally before you push.
- Everything lands on a branch; nothing is committed directly to `main`.

## Code conventions

See [AGENTS.md](AGENTS.md) for the short version:

- **Style**: standard Go — gofmt, `go vet`. Explicit over clever.
- **Errors**: wrap with context, never swallow.
- **Tests**: table-driven, in `_test.go` alongside the code.
- **Logging**: hyper-concise. Include what is needed, minimize words.
28 changes: 22 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-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks test-hooks help

# Binary name
BINARY=nightshift
Expand Down Expand Up @@ -57,8 +57,12 @@ deps:
go mod download
go mod tidy

# Run the commit-msg validator fixture tests
test-hooks:
@bash scripts/commit-msg-test.sh

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

# Show help
help:
Expand All @@ -73,12 +77,24 @@ help:
@echo " clean - Clean build artifacts"
@echo " deps - Download and tidy dependencies"
@echo " check - Run tests and lint"
@echo " test-hooks - Run the commit-msg validator test suite"
@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 hooks (pre-commit, commit-msg) and commit template"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git hooks and the commit message template
# The hooks directory is shared by every worktree, so link targets must resolve
# to the MAIN worktree (--git-common-dir/..), not the current one
# (--show-toplevel). Linking a throwaway worktree's path would leave a dangling
# symlink once that worktree is removed, and git skips broken hooks silently.
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="$$(git rev-parse --git-path hooks)"; \
root="$$(cd "$$(git rev-parse --path-format=absolute --git-common-dir)/.." && pwd)"; \
mkdir -p "$$hooks"; \
ln -sf "$$root/scripts/pre-commit.sh" "$$hooks/pre-commit"; \
echo "✓ pre-commit hook installed ($$hooks/pre-commit → scripts/pre-commit.sh)"; \
ln -sf "$$root/scripts/commit-msg.sh" "$$hooks/commit-msg"; \
echo "✓ commit-msg hook installed ($$hooks/commit-msg → scripts/commit-msg.sh)"
@git config commit.template .gitmessage
@echo "✓ commit template configured (commit.template → .gitmessage)"
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,33 @@ Each task has a default cooldown interval to prevent the same task from running

## Development

### Pre-commit hooks
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide, including the commit message convention.

Install the git pre-commit hook to catch formatting and vet issues before pushing:
### Git hooks

Install the git hooks and commit template in one step:

```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 wires up:
- **pre-commit** (`scripts/pre-commit.sh`) — gofmt, `go vet`, and `go build` on staged Go files
- **commit-msg** (`scripts/commit-msg.sh`) — validates the commit subject against the convention
- **commit template** (`.gitmessage`) — prefills `git commit` with the format guide

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

### Commit messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/):

```
<type>[(<scope>)][!]: <description>
```

Types: `feat`, `fix`, `docs`, `chore`, `test`, `refactor`, `perf`, `build`, `ci`, `style`, `revert`. Subjects are imperative and not sentence-cased (acronyms and digits are fine), carry no trailing period, and stay at 72 characters or fewer. Existing history predates this convention and is grandfathered — CI only checks the commits in a pull request. Full rules: [CONTRIBUTING.md](CONTRIBUTING.md#commit-messages).

## Uninstalling

```bash
Expand Down
Loading