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
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ 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 hooks"
@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)"
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,38 @@ 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 git hooks to catch formatting, vet, build, 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 symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit` and `scripts/commit-msg.sh` into `.git/hooks/commit-msg`.

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 message hook accepts:

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

Accepted types are `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `ci`, `perf`, `style`, and `revert`. The hook normalizes safe cases such as `FIX: thing`, `feat - thing`, and extra whitespace, while preserving the body and trailers. Merge, revert, `fixup!`, and `squash!` commits are allowed.

Valid examples:

```text
feat: add project setup command
fix(config): preserve default provider
docs: document hook installation
```

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

## Uninstalling
Expand Down
2 changes: 1 addition & 1 deletion internal/tasks/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain
Type: TaskCommitNormalize,
Category: CategoryPR,
Name: "Commit Message Normalizer",
Description: "Standardize commit message format",
Description: "Enforce repository commit message conventions via a git hook",
CostTier: CostLow,
RiskLevel: RiskLow,
DefaultInterval: 24 * time.Hour,
Expand Down
101 changes: 101 additions & 0 deletions scripts/commit-msg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# commit-msg hook for nightshift
# Install: make install-hooks (or: ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg)
set -euo pipefail

MSG_FILE=${1:-}
if [[ -z "$MSG_FILE" || ! -f "$MSG_FILE" ]]; then
echo "commit-msg: missing commit message file" >&2
exit 1
fi

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

trim() {
local value=$1
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}

subject_line_number=$(
awk '
/^[[:space:]]*#/ { next }
/^[[:space:]]*$/ { next }
{ print NR; exit }
' "$MSG_FILE"
)

if [[ -z "$subject_line_number" ]]; then
echo "commit-msg: empty commit message" >&2
exit 1
fi

subject=$(
awk -v line="$subject_line_number" 'NR == line { print; exit }' "$MSG_FILE"
)
subject=$(trim "$subject")

allow_special=false
case "$subject" in
Merge\ *|Revert\ *|fixup!\ *|squash!\ *)
allow_special=true
;;
esac

normalized=$subject
if [[ "$allow_special" == false ]]; then
if [[ "$normalized" =~ ^([A-Za-z]+)(\([A-Za-z0-9._/-]+\))?[[:space:]]*[-:][[:space:]]*(.+)$ ]]; then
type_part=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')
scope_part=${BASH_REMATCH[2]:-}
summary_part=$(trim "${BASH_REMATCH[3]}")
normalized="${type_part}${scope_part}: ${summary_part}"
fi
fi

if [[ "$normalized" != "$subject" ]]; then
tmp=$(mktemp)
awk -v line="$subject_line_number" -v replacement="$normalized" '
NR == line { print replacement; next }
{ print }
' "$MSG_FILE" > "$tmp"
cat "$tmp" > "$MSG_FILE"
rm -f "$tmp"
subject=$normalized
fi

if [[ "$allow_special" == true ]]; then
exit 0
fi

if ! [[ "$subject" =~ ^($TYPES)(\([A-Za-z0-9._/-]+\))?:[[:space:]][^[:space:]].*$ ]]; then
cat >&2 <<'EOF'
commit-msg: expected commit message format:
type: summary
type(scope): summary

Accepted types:
feat, fix, docs, test, refactor, chore, build, ci, perf, style, revert

Examples:
feat: add project setup command
fix(config): preserve default provider
docs: document hook installation

Merge, Revert, fixup!, and squash! commits are allowed.
EOF
exit 1
fi

summary=${subject#*: }
if [[ -z "$(trim "$summary")" ]]; then
echo "commit-msg: summary must not be empty" >&2
exit 1
fi

if (( ${#subject} > SUBJECT_MAX )); then
echo "commit-msg: subject must be ${SUBJECT_MAX} characters or fewer" >&2
echo " ${subject}" >&2
exit 1
fi