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
80 changes: 80 additions & 0 deletions cmd/commit-lint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Command commit-lint validates a Git commit message against the Nightshift
// Conventional Commits convention. It reads the message from the file path
// passed as its first argument (the contract used by Git's commit-msg hook) and
// exits with a non-zero status if any violations are found.
package main

import (
"fmt"
"os"
"strings"

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

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: commit-lint <message-file>")
os.Exit(2)
}
path := os.Args[1]
data, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "commit-lint: read %s: %v\n", path, err)
os.Exit(2)
}

// Strip trailing comment lines that Git appends for the user's reference.
msg := stripComments(string(data))

violations := commits.Validate(msg)
if len(violations) == 0 {
return
}

fmt.Fprintln(os.Stderr, "commit message does not follow the Conventional Commits convention:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - %s\n", v.Error())
}
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Run the normalizer to auto-fix, or edit your message to match:")
fmt.Fprintln(os.Stderr, " <type>(<scope>): <subject> (imperative, <=72 chars)")
os.Exit(1)
}

// stripComments removes Git's trailing "#" comment block from the commit
// message file and trims trailing blank lines.
func stripComments(s string) string {
rawLines := splitLines(s)
var keep []string
for _, line := range rawLines {
if strings.HasPrefix(line, "#") {
continue
}
keep = append(keep, line)
}
for len(keep) > 0 && keep[len(keep)-1] == "" {
keep = keep[:len(keep)-1]
}
out := ""
for i, l := range keep {
if i > 0 {
out += "\n"
}
out += l
}
return out
}

func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
104 changes: 104 additions & 0 deletions docs/commit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Commit Message Convention

This project follows the [Conventional Commits](https://www.conventionalcommits.org/)
specification. All commits are expected to match the shape below and are
enforced by a `commit-msg` hook (see [Installing the hook](#installing-the-hook)).

## Format

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

<body wrapped to 72 columns>

<Trailers>
```

- **type** *(required)* — lowercase, one of the allowed types listed below.
- **scope** *(optional)* — lowercase, one of the allowed scopes. Omit the
parentheses entirely when there is no scope.
- **subject** *(required)* — imperative mood (`add`, not `added`/`adds`), no
trailing period, hard limit of **72 characters**. The first letter is
lower-cased.
- **body** *(optional)* — separated from the subject by a blank line, hard-wrapped
to **72 columns**. Paragraphs are separated by blank lines.
- **trailers** *(optional)* — separated from the body by a blank line, in
`Key: value` form. `Nightshift-*` trailers required for task tracking are
sorted to the end automatically.

A trailing `!` after the type/scope marks a breaking change
(e.g. `feat(api)!: drop v1`).

## Allowed types

| Type | Use for |
|------------|---------------------------------------------------------------|
| `feat` | A new feature |
| `fix` | A bug fix |
| `docs` | Documentation only |
| `style` | Formatting, whitespace, semicolons — no code change |
| `refactor` | Code change that neither fixes a bug nor adds a feature |
| `perf` | A change that improves performance |
| `test` | Adding or correcting tests |
| `build` | Build system or external dependencies |
| `ci` | CI configuration files and scripts |
| `chore` | Repetitive tasks, tooling, repo maintenance |
| `revert` | Reverting a previous commit |

## Allowed scopes

`commits`, `config`, `scheduler`, `providers`, `agents`, `cli`, `docs`,
`website`, `db`, `reporting`, `security`, `setup`, `orchestrator`, `budget`.

Unknown scopes are not rejected outright (new subsystems appear before their
scope is registered), but tooling will warn. Keep scope names lowercase and
short.

## Examples

Well-formed:

```
feat(commits): add message normalizer

Parses raw commit messages into type/scope/subject/body/trailers and
rewrites them to the canonical format. Idempotent on already-conforming
messages.

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

Needs normalization (the normalizer rewrites this to the form above):

```
FEAT(Commits): Added message normalizer.
```

The validator reports:

- `unknown_type` / `missing_type` — type missing or not in the allowed list
- `oversize_subject` — subject longer than 72 characters
- `non_imperative` — leading verb is past tense, gerund, or third person
- `trailing_period` — subject ends with a period
- `unwrapped_body` — a body line longer than 72 columns

## Programmatic use

```go
import "github.com/marcus/nightshift/internal/commits"

normalized, err := commits.Normalize(raw) // auto-fix
violations := commits.Validate(raw) // report
ok := commits.Conforms(raw) // bool
```

## Installing the hook

```sh
scripts/install-commit-msg.sh
```

This writes `.git/hooks/commit-msg`, which runs `cmd/commit-lint` on the staged
message and rejects the commit if it does not conform. Re-run the script after a
fresh clone.
9 changes: 9 additions & 0 deletions internal/commits/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package commits

import "errors"

// Sentinel errors returned by Parse.
var (
// ErrEmptyMessage is returned when the input contains no non-blank lines.
ErrEmptyMessage = errors.New("commits: empty commit message")
)
Loading