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
44 changes: 44 additions & 0 deletions .github/workflows/commit-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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: Run normalizer test suite
run: bash tests/run-commit-msg-tests.sh

- name: Validate commit messages in the PR range
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
failed=0
checked=0
for sha in $(git rev-list "$BASE_SHA..$HEAD_SHA"); do
checked=$((checked + 1))
message="$(git log -1 --format=%B "$sha")"
if ! printf '%s' "$message" | ./scripts/validate-commit-msg.sh - > /tmp/commit-lint.err 2>&1; then
echo "::error::$(git log -1 --format='%h %s' "$sha")"
sed 's/^/ /' /tmp/commit-lint.err
echo ""
failed=$((failed + 1))
fi
done
echo "checked $checked commit(s) in $BASE_SHA..$HEAD_SHA"
if [ "$failed" -gt 0 ]; then
echo "❌ $failed commit message(s) do not follow the project format."
echo " Fix them with: git rebase -i $BASE_SHA (reword the offending commits)"
exit 1
fi
echo "✅ all commit messages follow the project format"
103 changes: 103 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Contributing to Nightshift

## Commit messages

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/). The
format was not invented for this document — it is what the existing history
already does, and the tooling below just makes it consistent.

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

<optional body, wrapped at 72 columns>

<optional trailers>
```

- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
`build`, `ci`, `chore`, `revert`
- **scope** — optional, lowercase, e.g. `(runner)`, `(tui)`, `(config)`
- **`!`** — marks a breaking change, e.g. `feat(api)!: drop v1 endpoints`
- **subject** — lowercase, imperative ("add", not "adds"/"Added"), no trailing
period, 72 characters or fewer
- **body** — separated from the subject by a blank line
- **trailers** — last, e.g. `Co-Authored-By:`, `Nightshift-Task:`

### Good

```
feat(runner): add per-task timeout
fix: stop leaking the task context on cancel
docs: document the commit message format
feat(api)!: drop v1 endpoints
```

### Bad

```
Fix: Thing. → fix: thing
Update readme → docs: update readme
feat - add worker pool → feat: add worker pool
[feat] add worker pool → feat: add worker pool
feat: Add a really long subject that runs well past seventy-two characters
```

Merge, revert, `fixup!`, `squash!` and `amend!` messages are exempt — they are
passed through untouched by both the hook and CI.

### Install the hook (opt-in)

```
make install-hooks
```

This symlinks `scripts/pre-commit.sh` and `scripts/commit-msg.sh` into
`.git/hooks/`. Nothing installs itself; your git config is only changed when you
run that target.

The `commit-msg` hook first *normalizes* the message — it fixes case, trailing
punctuation, near-miss prefixes (`Fix:`, `feat -`, `[feat]`), missing space
after the colon, blank-line structure, and the blank line git needs before
a trailer block — and then *validates* the result.
Only what cannot be fixed unambiguously (unknown type, empty subject,
over-length subject) blocks the commit, and the error explains the fix.
Normalization is idempotent.

The hook never deletes comment lines. Git already does that itself, *after* the
hook runs and only when it should: an editor-authored message loses its
generated template, while `git commit -m` keeps a body line that happens to
start with `#`. The hook leaves the trailing template block untouched and
respects `core.commentChar`/`core.commentString`, so a body such as
`#123 explains why` survives exactly as it would without the hook. The one
consequence is that a trailing `#` line is assumed to be a template and is not
validated — a missed warning rather than a rejected commit.

### Bypass

```
NORMALIZE_COMMIT_MSG=0 git commit -m "..." # skip normalize + validate
git commit --no-verify -m "..." # skip all hooks
```

### How CI enforces it

`.github/workflows/commit-lint.yml` runs on pull requests and validates every
commit subject in the PR range with `scripts/validate-commit-msg.sh`, reporting
all failures at once. It only looks at commits in the PR — existing history is
never rewritten. Fix failures with `git rebase -i <base-sha>` and reword.

### Tooling

| Script | Purpose |
| --- | --- |
| `scripts/normalize-commit-msg.sh <file>` | Rewrite a commit message file in place |
| `scripts/validate-commit-msg.sh [--quiet] [<file>\|-]` | Validate; non-zero on failure |
| `scripts/commit-msg.sh` | The git hook (normalize, then validate) |
| `tests/run-commit-msg-tests.sh` | Test suite (`make test-commit-msg`) |

Both scripts are dependency-free POSIX shell — no Node, no commitlint.

## Code

Run `make check` before opening a PR (tests, lint, and the commit message
tests). See `AGENTS.md` for repository conventions.
13 changes: 10 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-commit-msg test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help

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

# Run the commit message normalizer/validator tests
test-commit-msg:
@bash tests/run-commit-msg-tests.sh

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

# Show help
help:
Expand All @@ -75,10 +79,13 @@ 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 " test-commit-msg - Run the commit message normalizer tests"
@echo " help - Show this help"

# Install git pre-commit hook
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)"
18 changes: 18 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# commit-msg hook for nightshift
# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg)
#
# Normalizes the commit message in place, then validates it. Set
# NORMALIZE_COMMIT_MSG=0 to bypass both steps for a single commit, or use
# `git commit --no-verify`.
set -euo pipefail

if [[ "${NORMALIZE_COMMIT_MSG:-1}" == "0" ]]; then
exit 0
fi

MSG_FILE="$1"
ROOT="$(git rev-parse --show-toplevel)"

"$ROOT/scripts/normalize-commit-msg.sh" "$MSG_FILE"
"$ROOT/scripts/validate-commit-msg.sh" "$MSG_FILE"
188 changes: 188 additions & 0 deletions scripts/normalize-commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env sh
#
# normalize-commit-msg.sh — rewrite a commit message file into the repo's
# Conventional Commits format, fixing only what can be fixed unambiguously.
#
# usage: scripts/normalize-commit-msg.sh <commit-msg-file>
#
# Fixes applied:
# - strips trailing whitespace, leaving git's comment template alone
# - collapses runs of blank lines and trims leading/trailing blanks
# - ensures a blank line between the subject and the body
# - rewrites near-miss prefixes: "Fix: x", "feat - x", "[feat] x", "fix:x"
# - lowercases the type token and the first word of the description
# (acronyms such as "API" are left alone)
# - removes a trailing period from the subject
#
# Never touches merge, revert, fixup!, squash! or amend! messages, and never
# invents a type for a subject that does not already look conventional.
# Running it twice produces the same result as running it once.
set -eu

TYPES="feat fix docs style refactor perf test build ci chore revert"

if [ $# -ne 1 ]; then
echo "usage: $(basename "$0") <commit-msg-file>" >&2
exit 2
fi

MSG_FILE=$1
if [ ! -f "$MSG_FILE" ]; then
echo "normalize-commit-msg: no such file: $MSG_FILE" >&2
exit 2
fi

lower() {
printf '%s' "$1" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz'
}

is_type() {
for _t in $TYPES; do
[ "$1" = "$_t" ] && return 0
done
return 1
}

# Git strips comment lines itself, after this hook runs, and only when it would
# have: an editor-authored message loses its generated template, but
# `git commit -m` uses cleanup=whitespace and keeps a body line that happens to
# start with the comment character. Deleting every such line here would
# silently drop user-authored body text, so instead the trailing run of
# blank/comment lines -- the template, when there is one -- is carved off,
# left completely untouched, and handed back to git verbatim.
comment_prefix() {
_cc=$(git config --get core.commentString 2>/dev/null) || _cc=""
[ -n "$_cc" ] || { _cc=$(git config --get core.commentChar 2>/dev/null) || _cc=""; }
case "$_cc" in
"" | auto) printf '#' ;;
*) printf '%s' "$_cc" ;;
esac
}

# First line of that trailing run, or one past the end when there is none.
split=$(awk -v cc="$(comment_prefix)" '
{ line[NR] = $0 }
END {
i = NR
found = 0
while (i >= 1) {
if (line[i] == "") { i--; continue }
if (substr(line[i], 1, length(cc)) == cc) { found = 1; i--; continue }
break
}
print found ? i + 1 : NR + 1
}
' "$MSG_FILE")

trailing=$(sed -n "${split},\$p" "$MSG_FILE")

# Structural cleanup of everything above it: rstrip, collapse blank runs,
# trim edges.
if [ "$split" -gt 1 ]; then
cleaned=$(sed -n "1,$((split - 1))p" "$MSG_FILE" | awk '
{ sub(/[ \t\r]+$/, "") }
$0 == "" { blank = 1; next }
{
if (emitted && blank) print ""
blank = 0
emitted = 1
print
}
')
else
cleaned=""
fi

# Nothing but a template (or nothing at all): leave the file exactly as it is
# and let git report the empty message.
[ -n "$cleaned" ] || exit 0

subject=$(printf '%s\n' "$cleaned" | sed -n '1p')
body=$(printf '%s\n' "$cleaned" | awk 'NR > 1 { if (!seen && $0 == "") next; seen = 1; print }')

# Messages git generates or that reference another commit are left verbatim.
case "$subject" in
Merge\ * | Revert\ * | fixup!* | squash!* | amend!*)
exit 0
;;
esac

# Only rewrite subjects whose leading word is already a known type; anything
# else is left for the validator to reject with an explanation.
lead=$(printf '%s' "$subject" | sed -E 's/^[[:space:]]*\[?[[:space:]]*([A-Za-z]+).*/\1/')
if is_type "$(lower "$lead")"; then
s=$subject

# "[feat] x" / "[feat(api)] x" -> "feat: x" / "feat(api): x"
s=$(printf '%s' "$s" | sed -E 's/^[[:space:]]*\[[[:space:]]*([A-Za-z]+)([^]]*)\][[:space:]]*:?[[:space:]]*/\1\2: /')

# Normalize the separator and spacing: "feat - x", "fix:x" -> "feat: x"
s=$(printf '%s' "$s" | sed -E 's/^[[:space:]]*([A-Za-z]+)[[:space:]]*(\([^)]*\))?[[:space:]]*(!?)[[:space:]]*(:|-)[[:space:]]*/\1\2\3: /')

# Only keep going if those two rewrites actually produced a conventional
# subject. "Fix the bug" starts with a type word but has no separator to
# work with, so there is nothing that can be fixed unambiguously -- leave
# it exactly as written for the validator to reject, rather than
# half-rewriting a message that is going to be refused anyway.
if printf '%s' "$s" | grep -Eq '^[A-Za-z]+(\([^()]+\))?!?: .+'; then

# Lowercase the type token.
type_token=$(printf '%s' "$s" | sed -E 's/^([A-Za-z]+).*/\1/')
s="$(lower "$type_token")${s#"$type_token"}"

# Drop a trailing period.
s=$(printf '%s' "$s" | sed -E 's/[[:space:]]*\.+$//')

# Lowercase the first word of the description unless it looks like an
# acronym or CamelCase identifier (API, OAuth, HTTPStatus, ...).
case "$s" in
*": "*)
prefix=${s%%: *}
desc=${s#*: }
first_word=${desc%% *}
case "$(printf '%s' "$first_word" | cut -c2-)" in
*[A-Z]*) ;;
*)
head_char=$(printf '%s' "$desc" | cut -c1)
desc="$(lower "$head_char")$(printf '%s' "$desc" | cut -c2-)"
;;
esac
s="$prefix: $desc"
;;
esac

s=$(printf '%s' "$s" | sed -E 's/[[:space:]]+$//')
subject=$s
fi
fi

# Trailers must be their own paragraph or git will not parse them. Only a
# trailing run of hyphenated-key lines (Co-Authored-By, Nightshift-Task, ...)
# counts, so a body ending in "Note: ..." is left alone.
if [ -n "$body" ]; then
body=$(printf '%s\n' "$body" | awk '
{ line[NR] = $0 }
END {
start = NR + 1
while (start - 1 >= 1 && line[start - 1] ~ /^[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)+: .+$/)
start--
if (start > 1 && start <= NR && line[start - 1] != "")
insert = start
for (i = 1; i <= NR; i++) {
if (i == insert) print ""
print line[i]
}
}
')
fi

{
if [ -n "$body" ]; then
printf '%s\n\n%s\n' "$subject" "$body"
else
printf '%s\n' "$subject"
fi
if [ -n "$trailing" ]; then
printf '%s\n' "$trailing"
fi
} >"$MSG_FILE"
Loading