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
24 changes: 24 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
# commit-msg hook — invoke the commit message normalizer.
#
# Enable for this repo:
# git config core.hooksPath .githooks
# Or run: scripts/setup-hooks.sh

set -e

REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
NORMALIZER="$REPO_ROOT/scripts/commit-normalizer.sh"

if [ ! -x "$NORMALIZER" ]; then
echo "commit-msg: normalizer not executable at $NORMALIZER" >&2
echo " fix: chmod +x scripts/commit-normalizer.sh" >&2
exit 1
fi

# Allow opt-out for emergencies: COMMIT_NORMALIZER=0 git commit ...
if [ "${COMMIT_NORMALIZER:-1}" = "0" ]; then
exit 0
fi

exec "$NORMALIZER" "$1"
36 changes: 36 additions & 0 deletions .gitmessage
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# <type>(<scope>): <subject> ── 72 chars or fewer; imperative mood
#
# Allowed types:
# feat ── new feature
# fix ── bug fix
# docs ── documentation only
# style ── formatting; no code change
# refactor ── code change that neither fixes a bug nor adds a feature
# perf ── performance improvement
# test ── adding or correcting tests
# build ── build system or dependency change
# ci ── CI configuration change
# chore ── housekeeping; no production code change
# revert ── reverts a previous commit
#
# Scope is optional; use it for the affected area (e.g. parser, daemon).
# Append `!` after the type/scope to mark a breaking change.
#
# Examples:
# feat(parser): allow optional trailing comma
# fix: handle empty config without panicking
# refactor(daemon)!: replace polling with channel-based notify
#
# ── Body ──────────────────────────────────────────────────────────────
# Wrap at 72 chars. Explain *why* the change is needed; the diff already
# shows *what* changed. Reference issues with `Fixes #123` or `Refs #45`.
#
#
# ── Trailers ──────────────────────────────────────────────────────────
# Use a single blank line before trailers. Common ones:
# Co-Authored-By: Name <email@example.com>
# Fixes: #123
# Refs: #45
# Signed-off-by: Name <email@example.com>
# Nightshift-Task: <task-id>
# Nightshift-Ref: <task-url>
12 changes: 11 additions & 1 deletion 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 setup-hooks test-commit-normalizer help

# Binary name
BINARY=nightshift
Expand Down Expand Up @@ -76,9 +76,19 @@ help:
@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 " setup-hooks - Configure core.hooksPath + commit.template (commit-msg normalizer)"
@echo " test-commit-normalizer - Run commit-msg normalizer fixture 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)"

# Configure repo-tracked git hooks (.githooks) and commit template
setup-hooks:
@./scripts/setup-hooks.sh

# Run commit-msg normalizer fixture tests
test-commit-normalizer:
@./tests/commit-normalizer/run-tests.sh
102 changes: 102 additions & 0 deletions docs/commit-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Commit Message Conventions

Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/) for
all commit messages. A local git hook normalizes and validates each message,
so most contributors never need to think about formatting beyond following the
template.

## Format

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

<body — optional, wrap at 72 chars>

<trailers — optional>
```

- **type** *(required)* — one of: `feat`, `fix`, `docs`, `style`, `refactor`,
`perf`, `test`, `build`, `ci`, `chore`, `revert`.
- **scope** *(optional)* — the area touched (`parser`, `daemon`, `config`).
- **`!`** *(optional)* — appended to the type/scope to mark a breaking change.
- **subject** *(required)* — imperative, no trailing period, ≤ 72 characters
including the type prefix.
- **body** *(optional)* — separated from the subject by a blank line. Explain
*why* the change is needed.
- **trailers** *(optional)* — `Key: value` lines after a blank line. Common
trailers: `Co-Authored-By`, `Fixes`, `Refs`, `Signed-off-by`,
`Nightshift-Task`, `Nightshift-Ref`.

## Examples

Valid:

```
feat(parser): allow optional trailing comma
```

```
fix: handle empty config without panicking

When NIGHTSHIFT_CONFIG points at an empty file the daemon crashed on
startup. Treat empty as "use defaults".

Fixes: #123
```

```
refactor(daemon)!: replace polling with channel-based notify

BREAKING CHANGE: the `--poll-interval` flag is removed. Configure
notifications via `notify.channel` in the config file instead.
```

Invalid (and how the normalizer reacts):

| Message | What happens |
| -------------------------------------- | ------------------------------------------------- |
| `Fix typo` | Rejected — missing type prefix. |
| `FEAT: add thing` | Normalized → `feat: add thing` (lowercased type). |
| `feat: add thing ` | Normalized — extra whitespace trimmed. |
| `wibble: do stuff` | Rejected — `wibble` is not an allowed type. |
| `feat: ` *(empty description)* | Rejected — subject has no description. |
| 90-character `feat: …` subject | Rejected — exceeds 72-char limit. |

## Skip rules

The normalizer intentionally leaves these alone:

- Merge commits (`Merge branch …`, `Merge pull request …`).
- Fixup / squash / amend commits (`fixup!`, `squash!`, `amend!`).
- Revert commits (`Revert "…"`).

## Enabling the hook

Run once per clone:

```sh
scripts/setup-hooks.sh
```

This sets `core.hooksPath=.githooks` and `commit.template=.gitmessage`. After
that, every `git commit` runs through `scripts/commit-normalizer.sh`.

To bypass for a single commit:

```sh
COMMIT_NORMALIZER=0 git commit -m "..."
```

To disable entirely:

```sh
git config --unset core.hooksPath
```

## Testing the normalizer

```sh
tests/commit-normalizer/run-tests.sh
```

Fixture-driven; each case lives under `tests/commit-normalizer/cases/`.
184 changes: 184 additions & 0 deletions scripts/commit-normalizer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/bin/sh
# commit-normalizer.sh — language-agnostic Conventional Commits normalizer.
#
# Reads a commit message file, validates and normalizes its first line to
# Conventional Commits format `<type>(<scope>)!: <subject>` (scope and `!`
# optional), trims trailing whitespace, lowercases the type, and preserves
# the body and trailer block intact.
#
# Exit codes:
# 0 — message normalized (or intentionally skipped)
# 1 — unfixable violation (caller should reject the commit)
# 2 — usage error
#
# Skip rules: merge commits, fixup!/squash! commits, revert commits, and
# messages that already begin with "Revert " are left untouched.

set -eu

PROG=$(basename "$0")

usage() {
cat >&2 <<EOF
Usage: $PROG <commit-message-file>

Reads the file, normalizes the commit subject to Conventional Commits, and
writes the result back in place. Exits non-zero on unfixable violations.
EOF
exit 2
}

[ "$#" -eq 1 ] || usage
MSG_FILE=$1
[ -f "$MSG_FILE" ] || { echo "$PROG: not a file: $MSG_FILE" >&2; exit 2; }

# Subject length limit (configurable via env).
MAX_SUBJECT_LEN=${COMMIT_NORMALIZER_MAX_SUBJECT:-72}

# Allowed Conventional Commits types.
ALLOWED_TYPES="feat fix docs style refactor perf test build ci chore revert"

# Read the file, stripping comment lines (git's scissor lines start with '#').
RAW=$(awk '!/^#/' "$MSG_FILE")

# Determine the first non-empty line — that's the subject candidate.
SUBJECT=$(printf '%s\n' "$RAW" | awk 'NF { print; exit }')

# Strip CR (Windows line endings) and trailing whitespace from the subject.
SUBJECT=$(printf '%s' "$SUBJECT" | tr -d '\r' | sed 's/[[:space:]]*$//')

# Skip rules — leave the message untouched.
case "$SUBJECT" in
"Merge "*|"Merge branch "*|"Merge remote-tracking "*|"Merge pull request "*)
exit 0 ;;
"fixup! "*|"squash! "*|"amend! "*)
exit 0 ;;
"Revert "*|"revert: "*)
# Revert commits keep their auto-generated subject.
exit 0 ;;
"")
echo "$PROG: empty commit message" >&2
exit 1 ;;
esac

# Parse the subject into type, scope (optional), breaking marker, body.
# Pattern: type(scope)!: subject | type!: subject | type: subject
PARSE=$(printf '%s' "$SUBJECT" | awk '
{
line = $0
# Capture optional leading type / scope / !.
if (match(line, /^[A-Za-z]+(\([^)]+\))?!?:[[:space:]]/)) {
header = substr(line, 1, RLENGTH)
rest = substr(line, RLENGTH + 1)
# Split header into parts.
hd = header
sub(/:[[:space:]]*$/, "", hd)
bang = ""
if (hd ~ /!$/) { bang = "!"; sub(/!$/, "", hd) }
scope = ""
if (match(hd, /\([^)]+\)$/)) {
scope = substr(hd, RSTART + 1, RLENGTH - 2)
hd = substr(hd, 1, RSTART - 1)
}
type = hd
printf "%s\t%s\t%s\t%s\n", type, scope, bang, rest
} else {
printf "\t\t\t%s\n", line
}
}
')

TYPE=$(printf '%s' "$PARSE" | awk -F'\t' '{print $1}')
SCOPE=$(printf '%s' "$PARSE" | awk -F'\t' '{print $2}')
BANG=$(printf '%s' "$PARSE" | awk -F'\t' '{print $3}')
BODY=$(printf '%s' "$PARSE" | awk -F'\t' '{print $4}')

if [ -z "$TYPE" ]; then
cat >&2 <<EOF
$PROG: subject is not Conventional Commits format
got: $SUBJECT
expected: <type>(<scope>)!: <subject>
types: $ALLOWED_TYPES
example: feat(parser): allow optional trailing comma
EOF
exit 1
fi

# Lowercase the type.
TYPE=$(printf '%s' "$TYPE" | tr '[:upper:]' '[:lower:]')

# Validate type.
TYPE_OK=0
for t in $ALLOWED_TYPES; do
if [ "$t" = "$TYPE" ]; then TYPE_OK=1; break; fi
done
if [ "$TYPE_OK" -ne 1 ]; then
cat >&2 <<EOF
$PROG: unknown commit type "$TYPE"
allowed: $ALLOWED_TYPES
EOF
exit 1
fi

# Trim leading whitespace from the body portion of the subject.
BODY=$(printf '%s' "$BODY" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')

if [ -z "$BODY" ]; then
echo "$PROG: subject has no description after '$TYPE:'" >&2
exit 1
fi

# Rebuild the normalized subject.
if [ -n "$SCOPE" ]; then
NORMALIZED="$TYPE($SCOPE)$BANG: $BODY"
else
NORMALIZED="$TYPE$BANG: $BODY"
fi

# Enforce subject length.
SUBJECT_LEN=$(printf '%s' "$NORMALIZED" | awk '{print length}')
if [ "$SUBJECT_LEN" -gt "$MAX_SUBJECT_LEN" ]; then
cat >&2 <<EOF
$PROG: subject too long ($SUBJECT_LEN > $MAX_SUBJECT_LEN chars)
subject: $NORMALIZED
tip: move detail into the body (blank line, then explanation)
EOF
exit 1
fi

# Reassemble the message: normalized subject + remaining lines after the
# original subject. We preserve the body and trailer block byte-for-byte
# (minus trailing whitespace per line). Comment lines from git templates
# are stripped — git would do this anyway.
TMP=$(mktemp "${TMPDIR:-/tmp}/commit-normalizer.XXXXXX")
trap 'rm -f "$TMP"' EXIT INT TERM

awk -v normalized="$NORMALIZED" '
BEGIN { subject_done = 0 }
/^#/ { next }
{
if (!subject_done && $0 ~ /[^[:space:]]/) {
print normalized
subject_done = 1
next
}
# Strip trailing whitespace from every line.
sub(/[[:space:]]+$/, "", $0)
print
}
' "$MSG_FILE" > "$TMP"

# Collapse trailing blank lines down to one terminating newline.
awk '
{ lines[NR] = $0 }
END {
n = NR
while (n > 0 && lines[n] == "") n--
for (i = 1; i <= n; i++) print lines[i]
}
' "$TMP" > "$TMP.2"
mv "$TMP.2" "$MSG_FILE"
rm -f "$TMP"
trap - EXIT INT TERM

exit 0
Loading