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
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-verbose test-race coverage coverage-html lint clean deps check install calibrate-providers install-hooks test-commit-msg help

# Binary name
BINARY=nightshift
Expand Down Expand Up @@ -75,10 +75,17 @@ 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 - Test the commit message hook"
@echo " help - Show this help"

# Install git pre-commit hook
# Install git hooks
install-hooks:
@ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
@ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg
@echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)"
@echo "✓ commit-msg hook installed (.git/hooks/commit-msg → scripts/commit-msg.sh)"

# Test the commit message hook
test-commit-msg:
@./scripts/commit-msg_test.sh
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,20 +258,51 @@ Each task has a default cooldown interval to prevent the same task from running

## Development

### Pre-commit hooks
### Git hooks

Install the git pre-commit hook to catch formatting and vet issues before pushing:
Install the optional local hooks to catch code and commit-message issues before pushing:

```bash
make install-hooks
```

This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs:
This installs symlinks for `scripts/pre-commit.sh` and `scripts/commit-msg.sh` in
`.git/hooks`. 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

To bypass in a pinch: `git commit --no-verify`
The commit-msg hook requires a Conventional Commit-style first meaningful line:

```text
type: summary
type(scope): summary
```

Use one of these lowercase types: `feat`, `fix`, `docs`, `style`, `refactor`,
`perf`, `test`, `build`, `ci`, or `chore`. Scopes are optional and use lowercase
letters, digits, `.`, `_`, `/`, or `-`. Add `!` before the colon for a breaking
change:

```text
feat: add task filtering
fix(scheduler): handle empty queues
refactor(config)!: remove legacy keys
```

Blank lines and Git comment lines before the subject are ignored. Git-generated
merge and revert subjects, plus `fixup!` and `squash!` subjects, are exempt. The
hook only validates the subject, so commit bodies and required trailers remain
unchanged:

```text
Nightshift-Task: task-id
Nightshift-Ref: https://github.com/marcus/nightshift
```

Run the hook tests with `make test-commit-msg`. To bypass either hook in a pinch,
use `git commit --no-verify`.

## Uninstalling

Expand Down
50 changes: 50 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# commit-msg hook for Nightshift
# Install: make install-hooks
set -euo pipefail

MESSAGE_FILE=${1:-}
ALLOWED_TYPES="feat|fix|docs|style|refactor|perf|test|build|ci|chore"

fail() {
local subject=${1:-"<empty>"}

cat >&2 <<EOF
Invalid commit subject: ${subject}
Expected: type: summary or type(scope): summary
Breaking changes: type!: summary or type(scope)!: summary
Allowed types: ${ALLOWED_TYPES//|/, }
Examples: feat: add task filtering
fix(scheduler): handle empty queues
EOF
exit 1
}

if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then
fail "<missing commit message file>"
fi

subject=""
while IFS= read -r line || [[ -n "$line" ]]; do
# Git may provide a CRLF-formatted message (for example, from an editor).
line=${line%$'\r'}

[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue

subject=$line
break
done < "$MESSAGE_FILE"

[[ -n "$subject" ]] || fail

merge_re='^Merge[[:space:]].+$'
revert_re='^Revert[[:space:]]".+"$'
autosquash_re='^(fixup|squash)![[:space:]].+$'

if [[ "$subject" =~ $merge_re || "$subject" =~ $revert_re || "$subject" =~ $autosquash_re ]]; then
exit 0
fi

subject_re="^(${ALLOWED_TYPES})(\\([a-z0-9][a-z0-9._/-]*\\))?!?: [^[:space:]](.*[^[:space:]])?$"
[[ "$subject" =~ $subject_re ]] || fail "$subject"
60 changes: 60 additions & 0 deletions scripts/commit-msg_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
HOOK="$SCRIPT_DIR/commit-msg.sh"
TEST_DIR=$(mktemp -d)
trap 'rm -rf "$TEST_DIR"' EXIT

pass=0
fail=0

run_case() {
local expectation=$1
local name=$2
local message=$3
local message_file="$TEST_DIR/message"

printf '%s' "$message" > "$message_file"

if "$HOOK" "$message_file" >/dev/null 2>&1; then
actual=valid
else
actual=invalid
fi

if [[ "$actual" == "$expectation" ]]; then
printf 'ok - %s\n' "$name"
pass=$((pass + 1))
else
printf 'not ok - %s (expected %s, got %s)\n' "$name" "$expectation" "$actual"
fail=$((fail + 1))
fi
}

run_case valid "simple subject" 'feat: add task filtering'
run_case valid "scoped subject" 'fix(scheduler): handle empty queues'
run_case valid "breaking change without scope" 'feat!: replace task configuration'
run_case valid "breaking change with scope" 'refactor(config)!: remove legacy keys'
run_case valid "body and Nightshift trailers" $'docs: explain task selection\n\nMore detail.\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://github.com/marcus/nightshift\n'
run_case valid "leading comments and blank lines" $'# Please enter a commit message.\n\nchore: refresh generated files\n'
run_case valid "CRLF message" $'test(parser): cover invalid input\r\n\r\nBody.\r\n'
run_case valid "generated merge subject" "Merge branch 'main' into feature"
run_case valid "generated revert subject" 'Revert "feat: add task filtering"'
run_case valid "generated fixup subject" 'fixup! feat: add task filtering'
run_case valid "generated squash subject" 'squash! fix(scheduler): handle empty queues'

run_case invalid "empty message" ''
run_case invalid "comments-only message" $'# Please enter a commit message.\n# Lines starting with # are ignored.\n'
run_case invalid "unknown type" 'feature: add task filtering'
run_case invalid "uppercase type" 'Feat: add task filtering'
run_case invalid "scope with whitespace" 'fix(task queue): handle empty queues'
run_case invalid "missing summary" 'fix:'
run_case invalid "missing space after colon" 'fix:no separating space'
run_case invalid "extra space after colon" 'fix: too much space'
run_case invalid "leading whitespace" ' fix: leading whitespace'
run_case invalid "trailing whitespace" 'fix: trailing whitespace '
run_case invalid "malformed breaking marker" 'fix(!): malformed marker'

printf '\n%d passed, %d failed\n' "$pass" "$fail"
[[ "$fail" -eq 0 ]]