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

on:
pull_request:
branches: [main]

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

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'

- name: Lint commit messages added by this PR
run: |
go run ./cmd/commitmsg lint \
--range "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
57 changes: 57 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Contributing to Nightshift

Thanks for helping out. This is a short guide; the README covers installation
and configuration, and `docs/` covers individual subsystems.

## Getting set up

```bash
go build ./... # or: make build
make test # go test ./...
make lint # golangci-lint, if installed
make install-hooks # opt-in git hooks (recommended)
```

`make install-hooks` symlinks the repo's hooks into `.git/hooks/`:

* **pre-commit** — gofmt, `go vet`, `go build` on staged Go files
* **commit-msg** — normalizes and validates the commit message

Nothing is installed automatically; run the command when you want the hooks.
Bypass them once with `git commit --no-verify`.

## Commit messages

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

```
type(scope): subject line

Optional body, wrapped at 72 columns (the linter's hard limit is 80).

Optional-Trailer: value
```

Allowed types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
`build`, `ci`, `chore`, and `revert`. Subjects are imperative, lowercase, have
no trailing period, and stay within 72 characters.

The full standard, with examples and the exact rules the linter enforces, is in
[docs/commit-messages.md](docs/commit-messages.md).

Check your messages before pushing:

```bash
make commit-lint # origin/main..HEAD
make commit-lint RANGE=HEAD~20..HEAD # any range
```

CI runs the same check over the commits a pull request adds. Existing history is
not retroactively enforced.

## Pull requests

* Keep changes focused; separate refactors from behaviour changes.
* Add or update tests for behaviour you change — `make test` must pass.
* Run `gofmt -w .` (the pre-commit hook checks this).
* Update the docs when you change user-visible behaviour.
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
.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 commit-lint help

# Binary name
BINARY=nightshift
PKG=./cmd/nightshift

# Commit range checked by `make commit-lint`
RANGE?=origin/main..HEAD

# Build the binary
build:
go build -o $(BINARY) $(PKG)
Expand Down Expand Up @@ -75,10 +78,17 @@ 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 " commit-lint - Lint commit messages in RANGE (default: origin/main..HEAD)"
@echo " help - Show this help"

# Install git pre-commit hook
# Install 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)"
@ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
@echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)"

# Lint commit messages in RANGE (default: commits not yet on main)
commit-lint:
go run ./cmd/commitmsg lint --range $(RANGE)
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,21 +258,36 @@ 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
```

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 and checks it against the
standard.

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

### Commit messages

Nightshift uses Conventional Commits (`type(scope): subject`). See
[docs/commit-messages.md](docs/commit-messages.md) for the full standard, and
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.

```bash
make commit-lint # check origin/main..HEAD
make commit-lint RANGE=HEAD~20..HEAD # check any range
```

## Uninstalling

```bash
Expand Down
211 changes: 211 additions & 0 deletions cmd/commitmsg/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Command commitmsg normalizes and lints commit messages against the nightshift
// commit message standard documented in docs/commit-messages.md.
//
// Usage:
//
// commitmsg normalize <file> rewrite the message file in place
// commitmsg lint <file> validate one message file ("-" for stdin)
// commitmsg lint --range <rev-range> validate every commit in a range
// commitmsg lint --range <r> --report-only
// report violations but always exit 0
//
// It exits 1 when a message violates the standard and 2 on usage or I/O errors.
package main

import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"strings"

"github.com/marcus/nightshift/internal/commitmsg"
)

const (
exitViolation = 1
exitUsage = 2
)

func main() {
if len(os.Args) < 2 {
usage()
os.Exit(exitUsage)
}

switch os.Args[1] {
case "normalize":
os.Exit(runNormalize(os.Args[2:]))
case "lint":
os.Exit(runLint(os.Args[2:]))
case "-h", "--help", "help":
usage()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "commitmsg: unknown command %q\n\n", os.Args[1])
usage()
os.Exit(exitUsage)
}
}

func usage() {
fmt.Fprint(os.Stderr, `commitmsg — normalize and lint commit messages

Usage:
commitmsg normalize <file> rewrite a message file in place
commitmsg lint [file] validate a message file ("-" or
omitted reads stdin)
commitmsg lint --range <rev-range> validate every commit in a range
commitmsg lint --range <r> --report-only report but always exit 0

The standard is documented in docs/commit-messages.md.
`)
}

func runNormalize(args []string) int {
fs := flag.NewFlagSet("normalize", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return exitUsage
}
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "commitmsg normalize: expected exactly one message file")
return exitUsage
}

path := fs.Arg(0)
raw, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "commitmsg normalize: %v\n", err)
return exitUsage
}

normalized := commitmsg.Normalize(string(raw))
if normalized == string(raw) {
return 0
}
info, err := os.Stat(path)
mode := os.FileMode(0o644)
if err == nil {
mode = info.Mode().Perm()
}
if err := os.WriteFile(path, []byte(normalized), mode); err != nil {
fmt.Fprintf(os.Stderr, "commitmsg normalize: %v\n", err)
return exitUsage
}
return 0
}

func runLint(args []string) int {
fs := flag.NewFlagSet("lint", flag.ContinueOnError)
revRange := fs.String("range", "", "validate every commit in this git rev-range instead of a file")
reportOnly := fs.Bool("report-only", false, "print violations but always exit 0")
if err := fs.Parse(args); err != nil {
return exitUsage
}

if *revRange != "" {
if fs.NArg() > 0 {
fmt.Fprintln(os.Stderr, "commitmsg lint: --range cannot be combined with a file argument")
return exitUsage
}
return lintRange(*revRange, *reportOnly)
}

msg, err := readMessage(fs.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "commitmsg lint: %v\n", err)
return exitUsage
}
issues := commitmsg.Lint(msg)
if len(issues) == 0 {
return 0
}
report(os.Stderr, firstLine(msg), issues)
if *reportOnly {
return 0
}
return exitViolation
}

func readMessage(path string) (string, error) {
if path == "" || path == "-" {
raw, err := io.ReadAll(os.Stdin)
return string(raw), err
}
raw, err := os.ReadFile(path)
return string(raw), err
}

func lintRange(revRange string, reportOnly bool) int {
shas, err := gitLines("rev-list", "--no-merges", revRange)
if err != nil {
fmt.Fprintf(os.Stderr, "commitmsg lint: %v\n", err)
return exitUsage
}
if len(shas) == 0 {
fmt.Printf("commitmsg: no commits in range %s\n", revRange)
return 0
}

bad := 0
for _, sha := range shas {
out, err := exec.Command("git", "log", "-1", "--pretty=%B", sha).Output()
if err != nil {
fmt.Fprintf(os.Stderr, "commitmsg lint: reading %s: %v\n", sha, err)
return exitUsage
}
msg := string(out)
issues := commitmsg.Lint(msg)
if len(issues) == 0 {
continue
}
bad++
report(os.Stderr, fmt.Sprintf("%s %s", shortSHA(sha), firstLine(msg)), issues)
}

fmt.Printf("commitmsg: checked %d commit(s) in %s, %d with violations\n", len(shas), revRange, bad)
if bad > 0 && !reportOnly {
fmt.Fprintln(os.Stderr, "\nSee docs/commit-messages.md for the standard.")
return exitViolation
}
return 0
}

func report(w io.Writer, header string, issues []commitmsg.Issue) {
fmt.Fprintf(w, "✗ %s\n", header)
for _, issue := range issues {
fmt.Fprintf(w, " %s\n", issue)
}
}

func gitLines(args ...string) ([]string, error) {
out, err := exec.Command("git", args...).Output()
if err != nil {
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
var lines []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
lines = append(lines, line)
}
}
return lines, nil
}

func firstLine(msg string) string {
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
return line
}
}
return "(empty message)"
}

func shortSHA(sha string) string {
if len(sha) > 8 {
return sha[:8]
}
return sha
}
Loading