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
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" "$@"
16 changes: 10 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,9 @@ 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)"
@./scripts/install-git-hooks.sh
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,19 +301,71 @@ 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:
./scripts/install-git-hooks.sh
```

This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs:
The installer writes `core.hooksPath` to this repository's local `.git/config`.
It does not change system- or user-level Git configuration. If the repository
already has a different local hooks path, the installer stops without changing
it; merge those hooks into `.githooks` or remove the local setting before
installing.

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 Git's comment-led combined-commit messages are preserved byte for
byte. Combined-commit detection honors Git's configured `core.commentChar` or
`core.commentString` prefix, falling back to Git's default `#` prefix. Other
comment-led subjects are validated normally, so a manual subject such as
`# invalid subject` is rejected. 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 check and preview normalization for a message manually:

```bash
message_file=$(mktemp)
printf '%s\n' ' FEAT (cli) : add status output ' > "$message_file"
./scripts/normalize-commit-message.sh "$message_file" && cat "$message_file"
rm "$message_file"
```

The command exits nonzero and leaves the file unchanged when the subject cannot
be normalized unambiguously.

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

## Uninstalling
Expand Down
28 changes: 28 additions & 0 deletions scripts/install-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/sh

# Enable this repository's version-controlled hooks without changing global Git
# configuration.
set -eu

repo_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd)

if ! git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "not a Git worktree: $repo_root" >&2
exit 1
fi

if existing_hooks_path=$(git -C "$repo_root" config --local --get core.hooksPath 2>/dev/null); then
if [ "$existing_hooks_path" != ".githooks" ]; then
cat >&2 <<EOF
repository already uses core.hooksPath=$existing_hooks_path
merge those hooks into .githooks or remove the local setting, then rerun this installer
EOF
exit 1
fi

echo "repository hooks already enabled (.githooks)"
exit 0
fi

git -C "$repo_root" config --local core.hooksPath .githooks
echo "repository hooks enabled (.githooks)"
182 changes: 182 additions & 0 deletions scripts/normalize-commit-message.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/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

comment_text=${first_line#"$comment_prefix"}
combination_prefix=' This is a combination of '
case "$comment_text" in
"$combination_prefix"*' commits.')
commit_count=${comment_text#"$combination_prefix"}
commit_count=${commit_count%' commits.'}
case "$commit_count" in
""|*[!0-9]*)
;;
*)
if [ "$commit_count" -ge 2 ]; then
exit 0
fi
;;
esac
;;
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