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

commit-lint:
name: Commit messages
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Validate pull request commit messages
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git fetch --no-tags origin "$BASE_REF"
# Only the commits unique to this pull request are checked; history
# already on the base branch is deliberately left alone.
base=$(git merge-base "origin/$BASE_REF" "$HEAD_SHA")
./scripts/check-commit-range.sh --base "$base" "$HEAD_SHA"
18 changes: 18 additions & 0 deletions .gitmessage.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# <type>(<optional scope>): <subject>
#
# Subject rules:
# - one of: feat fix docs refactor test chore build ci perf style revert
# - lowercase, imperative mood ("add", not "adds" or "Added")
# - 72 characters or fewer, no trailing period
# - add ! before the colon for a breaking change: feat(config)!: ...
#
# Body (optional): explain what and why, not how. Wrap at 72 columns.
# Leave a blank line between the subject and the body.
#
# Footers (optional):
# fixes #123
# BREAKING CHANGE: describe the migration
# Nightshift-Task: <task-id> (agent-authored commits)
# Nightshift-Ref: https://github.com/marcus/nightshift
#
# Full convention: docs/guides/commit-messages.md
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ 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.
- **Commits**: Conventional Commits — `<type>(<scope>): <lowercase imperative subject>`,
72 chars or fewer, no trailing period. See [docs/guides/commit-messages.md](docs/guides/commit-messages.md).
Agent-authored commits keep their `Nightshift-Task:` and `Nightshift-Ref:` trailers.
Enforced by the `commit-msg` hook (`make install-hooks`) and CI for pull request commits.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to nightshift are documented in this file.

## [Unreleased]

### Other
- **Commit message convention** — commits now follow Conventional Commits, documented in
[docs/guides/commit-messages.md](docs/guides/commit-messages.md). A `commit-msg` hook
(installed by `make install-hooks`, alongside a `.gitmessage.txt` template) validates
messages locally, and a `commit-lint` CI job validates the commits in a pull request.
Existing history is not rewritten — enforcement applies to new commits only. Agent
prompts now request conforming subjects while keeping the `Nightshift-Task` and
`Nightshift-Ref` trailers.

## [v0.3.3] - 2026-02-19

### Features
Expand Down
20 changes: 17 additions & 3 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-scripts test-verbose test-race coverage coverage-html lint lint-commits clean deps check install calibrate-providers install-hooks help

# Binary name
BINARY=nightshift
Expand All @@ -21,6 +21,10 @@ calibrate-providers:
test:
go test ./...

# Run shell script test suites
test-scripts:
@./scripts/commit-msg_test.sh

# Run tests with verbose output
test-verbose:
go test -v ./...
Expand All @@ -46,6 +50,10 @@ lint:
@which golangci-lint > /dev/null || (echo "golangci-lint not installed. Run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
golangci-lint run

# Validate commit messages on this branch against the default branch
lint-commits:
@./scripts/check-commit-range.sh --base origin/main

# Clean build artifacts
clean:
rm -f $(BINARY)
Expand All @@ -65,20 +73,26 @@ help:
@echo "Available targets:"
@echo " build - Build the binary"
@echo " test - Run all tests"
@echo " test-scripts - Run shell script test suites"
@echo " test-verbose - Run tests with verbose output"
@echo " test-race - Run tests with race detection"
@echo " coverage - Run tests with coverage report"
@echo " coverage-html - Generate HTML coverage report"
@echo " lint - Run golangci-lint"
@echo " lint-commits - Check commit messages against origin/main"
@echo " clean - Clean build artifacts"
@echo " deps - Download and tidy dependencies"
@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 hooks and commit message template"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git hooks and the commit message template
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)"
@git config commit.template .gitmessage.txt
@echo "✓ commit template set (commit.template → .gitmessage.txt)"
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,53 @@ 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 and the commit message template:

```bash
make install-hooks
```

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`, and points `commit.template` at
`.gitmessage.txt`.

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 validates the commit message against the convention below.

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

### Commit messages

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

```
<type>(<optional scope>): <lowercase imperative subject>
```

Subjects are 72 characters or fewer with no trailing period, and the type is one of
`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `build`, `ci`, `perf`, `style`, or
`revert`. Commits authored by a nightshift run also carry `Nightshift-Task:` and
`Nightshift-Ref:` trailers.

See [docs/guides/commit-messages.md](docs/guides/commit-messages.md) for the full
convention, including examples and the rules the validator applies.

Check your branch before opening a pull request:

```bash
make lint-commits # validates commits against origin/main
make test-scripts # runs the validator's own test suite
```

Existing history is left as-is — enforcement applies to new commits only. CI validates
the commits in a pull request, not the commits already on `main`.

## Uninstalling

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

nightshift uses [Conventional Commits](https://www.conventionalcommits.org/). Roughly
three quarters of the existing history already follows this shape — this guide makes it
explicit and enforceable for new work.

**History is not rewritten.** Enforcement applies to new commits only: the `commit-msg`
hook checks what you write locally, and the `commit-lint` CI job checks the commits in a
pull request. Commits already on `main` are left alone.

## Format

```
<type>(<optional scope>): <subject>

<optional body, wrapped at 72 columns>

<optional footers>
```

### Subject

| Rule | Detail |
| --- | --- |
| Type | One of the types below, lowercase |
| Scope | Optional, lowercase, in parentheses — a package or area (`orchestrator`, `internal/tasks`, `#19`) |
| Breaking | Append `!` before the colon (`feat(config)!: ...`) |
| Separator | A colon and a single space |
| Mood | Imperative — "add", not "adds", "added", or "Add" |
| Case | Starts lowercase; acronyms such as `JSONL` or `PR` are fine |
| Length | 72 characters or fewer, including the type and scope (characters, not bytes — accents and emoji count as one) |
| Punctuation | No trailing period |

### Types

| Type | Use for |
| --- | --- |
| `feat` | A new user-facing capability |
| `fix` | A bug fix |
| `docs` | Documentation only |
| `refactor` | A change that neither fixes a bug nor adds a feature |
| `test` | Adding or correcting tests |
| `chore` | Maintenance that does not fit elsewhere (version bumps, housekeeping) |
| `build` | Build system, `go.mod`, release packaging |
| `ci` | CI configuration and workflows |
| `perf` | A performance improvement |
| `style` | Formatting only, no behavior change (`gofmt`) |
| `revert` | Reverting a previous commit |

### Body

Optional. Separate it from the subject with a blank line and wrap at 72 columns.
Explain *what* changed and *why*; the diff already shows *how*.

The hook wraps at 72 by convention but only rejects body lines longer than 100
characters, so prose you deliberately keep on one line is not blocked. Trailers
and unbreakable single tokens such as URLs are exempt from the limit entirely.

### Footers

```
fixes #21
BREAKING CHANGE: budget.daily is now budget.per_day
```

Agent-authored commits produced by a nightshift run must keep their trailers, which the
validator accepts as footers:

```
Nightshift-Task: commit-normalize
Nightshift-Ref: https://github.com/marcus/nightshift
```

## Examples

Good — all drawn from the existing history:

```
feat(orchestrator): implement plan-implement-review loop (td-d50819)
fix: use week-aware avg daily usage in budget projection (td-f08410)
fix(providers): compute billable tokens excluding cached input
test(commands): add task CLI unit tests (td-190775)
refactor: replace WriteString(fmt.Sprintf) with fmt.Fprintf (#41)
fix: serialize provider config with correct YAML key names (fixes #20) (#43)
```

Avoid — also drawn from the existing history, with the conforming rewrite:

| Instead of | Write |
| --- | --- |
| `Bump version to v0.3.4` | `chore: bump version to v0.3.4` |
| `Add pre-commit hook for gofmt/vet/build (#44)` | `build: add pre-commit hook for gofmt/vet/build (#44)` |
| `Fix gofmt formatting issues across codebase` | `style: fix gofmt formatting across codebase` |
| `Update readme` | `docs: update readme` |
| `fix(task)` | `fix(task): guard nil task lookup` |
| `Initial plan` | `chore: add initial plan` |

Merge, revert, `fixup!`, and `squash!` subjects that git writes itself are passed
through unchanged.

## Tooling

Install the hooks and the message template once per clone:

```sh
make install-hooks
```

That symlinks `.git/hooks/pre-commit` and `.git/hooks/commit-msg` to the scripts in
`scripts/`, and points `commit.template` at `.gitmessage.txt` so `git commit` opens with
the format inline.

| Command | Does |
| --- | --- |
| `make install-hooks` | Install both hooks and the commit template |
| `make test-scripts` | Run the validator's own test suite |
| `make lint-commits` | Validate commits on your branch against `origin/main` |
| `scripts/commit-msg.sh <file>` | Validate a single message file |
| `scripts/check-commit-range.sh --base <ref> [<head>]` | Validate a range |

If the hook rejects a message you are confident about, `git commit --no-verify` bypasses
it. CI still checks the commits in the pull request.
10 changes: 8 additions & 2 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,10 @@ Description: %s
0. You are running autonomously. If the task is broad or ambiguous, choose a concrete, minimal scope that delivers value and state any assumptions in the description.
1. Work on a new branch and plan to submit a PR. Never work directly on the primary branch.%s
2. Before creating your branch, record the current branch name and plan to switch back after the PR is opened.
3. If you create commits, include a concise message with these git trailers:
3. If you create commits, use a Conventional Commits subject —
<type>(<optional scope>): <lowercase imperative subject> — 72 characters or
fewer, no trailing period, where type is one of feat, fix, docs, refactor,
test, chore, build, ci, perf, style, revert. Include these git trailers:
Nightshift-Task: %s
Nightshift-Ref: https://github.com/marcus/nightshift
4. Analyze the task requirements
Expand Down Expand Up @@ -771,7 +774,10 @@ Description: %s
## Instructions
0. Before creating your branch, record the current branch name. Create and work on a new branch. Never modify or commit directly to the primary branch.%s
When finished, open a PR. After the PR is submitted, switch back to the original branch. If you cannot open a PR, leave the branch and explain next steps.
1. If you create commits, include a concise message with these git trailers:
1. If you create commits, use a Conventional Commits subject —
<type>(<optional scope>): <lowercase imperative subject> — 72 characters or
fewer, no trailing period, where type is one of feat, fix, docs, refactor,
test, chore, build, ci, perf, style, revert. Include these git trailers:
Nightshift-Task: %s
Nightshift-Ref: https://github.com/marcus/nightshift
2. Implement the plan step by step
Expand Down
37 changes: 37 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -930,3 +930,40 @@ func TestRunTaskNoPRURL(t *testing.T) {
t.Errorf("OutputRef = %q, want empty", result.OutputRef)
}
}

func TestBuildPrompts_CommitConvention(t *testing.T) {
o := New()

task := &tasks.Task{
ID: "commit-convention",
Title: "Commit Convention",
Description: "Test commit instructions",
Type: tasks.TaskType("commit-normalize"),
}
plan := &PlanOutput{
Steps: []string{"step1"},
Description: "test plan",
}

// Both prompts must state the Conventional Commits subject format and keep
// the Nightshift trailers, so agent-authored commits pass the commit-msg
// hook. See docs/guides/commit-messages.md.
want := []string{
"Conventional Commits subject",
"<type>(<optional scope>): <lowercase imperative subject>",
"feat, fix, docs, refactor",
"Nightshift-Task: commit-normalize",
"Nightshift-Ref: https://github.com/marcus/nightshift",
}

for _, prompt := range map[string]string{
"plan": o.buildPlanPrompt(task),
"implement": o.buildImplementPrompt(task, plan, 1),
} {
for _, substr := range want {
if !strings.Contains(prompt, substr) {
t.Errorf("prompt missing %q\nGot:\n%s", substr, prompt)
}
}
}
}
Loading