Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

repo_root=$(git rev-parse --show-toplevel)
exec "$repo_root/scripts/normalize-commit-message.sh" "$@"
5 changes: 5 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu

repo_root=$(git rev-parse --show-toplevel)
exec "$repo_root/scripts/pre-commit.sh" "$@"
17 changes: 11 additions & 6 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-message test-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks help

# Binary name
BINARY=nightshift
Expand All @@ -18,9 +18,13 @@ calibrate-providers:
go run ./cmd/provider-calibration --repo "$$(pwd)" --codex-originator codex_cli_rs --min-user-turns 2

# Run all tests
test:
test: test-commit-message
go test ./...

# Run commit-message hook regression tests
test-commit-message:
./tests/commit-message-normalizer.sh

# Run tests with verbose output
test-verbose:
go test -v ./...
Expand Down Expand Up @@ -65,6 +69,7 @@ help:
@echo "Available targets:"
@echo " build - Build the binary"
@echo " test - Run all tests"
@echo " test-commit-message - Run commit-message hook regression tests"
@echo " test-verbose - Run tests with verbose output"
@echo " test-race - Run tests with race detection"
@echo " coverage - Run tests with coverage report"
Expand All @@ -75,10 +80,10 @@ 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 - Enable repository-managed git hooks"
@echo " help - Show this help"

# Install git pre-commit hook
# Enable repository-managed 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)"
@git config core.hooksPath .githooks
@echo "✓ repository hooks enabled (.githooks)"
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,19 +301,52 @@ Each task has a default cooldown interval to prevent the same task from running

## Development

### Pre-commit hooks
### Git hooks and commit messages

Install the git pre-commit hook to catch formatting and vet issues before pushing:
Enable the repository-managed hooks before committing:

```bash
make install-hooks
# Equivalent command:
git config core.hooksPath .githooks
```

This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs:
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 and validates Conventional Commit subjects in
the `type(scope)!: summary` format. The scope and `!` marker are optional. The
supported types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`,
`refactor`, `style`, and `test`.

The normalizer lowercases supported types, removes spacing around the optional
scope and breaking-change marker, collapses repeated subject whitespace, trims
leading and trailing blank lines, and removes trailing whitespace. Message body
paragraphs and Git trailers otherwise keep their original content.

Examples:

```text
feat(run): add pause command
fix(config)!: reject unknown provider keys
docs: explain hook installation
```

Merge and revert messages, `fixup!`, `squash!`, and `amend!` commits, stash
subjects, and comment-led messages generated by Git are preserved byte for byte.
Comment-led messages honor Git's configured `core.commentChar` or
`core.commentString` prefix, falling back to Git's default `#` prefix.
An empty message, unsupported type, missing summary, or malformed scope is
rejected without changing the message file.

Run the shell regression suite directly with:

```bash
make test-commit-message
```

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

## Uninstalling
Expand Down
170 changes: 170 additions & 0 deletions scripts/normalize-commit-message.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/bin/sh

# Normalize and validate a Conventional Commit message in place.
set -eu

usage() {
echo "usage: $0 <commit-message-file>" >&2
}

if [ "$#" -ne 1 ]; then
usage
exit 2
fi

message_file=$1
if [ ! -f "$message_file" ] || [ ! -r "$message_file" ] || [ ! -w "$message_file" ]; then
echo "commit message is not a readable, writable file: $message_file" >&2
exit 2
fi

# Git creates these subjects itself. Rewriting them can break merges, reverts,
# autosquash, stashes, and interactive rebases, so preserve the entire file.
first_line=$(awk '
{
line = $0
sub(/\r$/, "", line)
if (line !~ /^[[:space:]]*$/) {
sub(/^[[:space:]]+/, "", line)
print line
exit
}
}
' "$message_file")

comment_prefix=$(
git config --get-regexp '^core\.comment(char|string)$' 2>/dev/null |
awk '
{
sub(/^[^[:space:]]+[[:space:]]+/, "")
prefix = $0
}
END { print prefix }
'
)
case "$comment_prefix" in
""|auto)
comment_prefix="#"
;;
esac

case "$first_line" in
"$comment_prefix"*)
exit 0
;;
esac

case "$first_line" in
Merge\ *|Revert\ *|fixup\!\ *|squash\!\ *|amend\!\ *|WIP\ on\ *|index\ on\ *)
exit 0
;;
esac

tmp_file=$(mktemp "${TMPDIR:-/tmp}/nightshift-commit-msg.XXXXXX") || exit 2
cleanup() {
rm -f "$tmp_file"
}
trap cleanup 0
trap 'exit 1' 1 2 15

if ! awk '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}

function collapse(value) {
value = trim(value)
gsub(/[[:space:]]+/, " ", value)
return value
}

function supported(value) {
return value == "build" || value == "chore" || value == "ci" ||
value == "docs" || value == "feat" || value == "fix" ||
value == "perf" || value == "refactor" || value == "style" ||
value == "test"
}

{
line = $0
sub(/[[:space:]]+$/, "", line)
lines[++count] = line
}

END {
first = 1
while (first <= count && lines[first] == "") {
first++
}

last = count
while (last >= first && lines[last] == "") {
last--
}

if (first > last) {
exit 1
}

subject = collapse(lines[first])
colon = index(subject, ":")
if (colon == 0) {
exit 1
}

header = trim(substr(subject, 1, colon - 1))
summary = collapse(substr(subject, colon + 1))
if (summary == "") {
exit 1
}

breaking = ""
if (header ~ /![[:space:]]*$/) {
sub(/![[:space:]]*$/, "", header)
header = trim(header)
breaking = "!"
}

open = index(header, "(")
scope = ""
if (open > 0) {
if (substr(header, length(header), 1) != ")") {
exit 1
}
type = tolower(trim(substr(header, 1, open - 1)))
scope = collapse(substr(header, open + 1, length(header) - open - 1))
if (scope == "" || scope !~ /^[[:alnum:]_.\/#-]+$/) {
exit 1
}
} else {
type = tolower(trim(header))
if (type ~ /[()]/) {
exit 1
}
}

if (!supported(type)) {
exit 1
}

normalized = type
if (scope != "") {
normalized = normalized "(" scope ")"
}
print normalized breaking ": " summary

for (i = first + 1; i <= last; i++) {
print lines[i]
}
}
' "$message_file" > "$tmp_file"; then
cat >&2 <<'EOF'
invalid commit subject; expected type(scope)!: summary
supported types: build, chore, ci, docs, feat, fix, perf, refactor, style, test
EOF
exit 1
fi

cat "$tmp_file" > "$message_file"
Loading