chore: add pre-commit and commit-msg git hooks
Install via `make hooks`. Pre-commit runs format check, go vet, build, and tests on staged Go files with clear pass/fail output. Commit-msg validates Conventional Commits format. Both hooks skip gracefully and can be bypassed with --no-verify.
This commit is contained in:
parent
f4ec7103fe
commit
3186c2d216
4 changed files with 288 additions and 8 deletions
19
Makefile
19
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: all build install uninstall clean help test \
|
||||
.PHONY: all build install uninstall clean help test lint hooks \
|
||||
fantasy-check fantasy-diff fantasy-sync fantasy-patch
|
||||
|
||||
# Build variables
|
||||
|
|
@ -127,7 +127,7 @@ clean:
|
|||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
## test: Run tests
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
|
|
@ -135,6 +135,21 @@ test:
|
|||
fmt:
|
||||
@$(GO) fmt ./...
|
||||
|
||||
## lint: Run all linting checks (format + vet + build)
|
||||
lint: fmt vet
|
||||
@$(GO) build ./...
|
||||
@echo "Lint OK"
|
||||
|
||||
## hooks: Install git pre-commit and commit-msg hooks
|
||||
hooks:
|
||||
@echo "Installing git hooks..."
|
||||
@ln -sf ../../scripts/hooks/pre-commit .git/hooks/pre-commit
|
||||
@ln -sf ../../scripts/hooks/commit-msg .git/hooks/commit-msg
|
||||
@chmod +x .git/hooks/pre-commit .git/hooks/commit-msg
|
||||
@echo " ✓ pre-commit → scripts/hooks/pre-commit"
|
||||
@echo " ✓ commit-msg → scripts/hooks/commit-msg"
|
||||
@echo "Hooks installed. Skip with: git commit --no-verify"
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@$(GO) mod download
|
||||
|
|
|
|||
65
scripts/hooks/commit-msg
Executable file
65
scripts/hooks/commit-msg
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# picoclaw commit-msg hook
|
||||
#
|
||||
# Validates commit messages against Conventional Commits 1.0.0.
|
||||
# Install: make hooks
|
||||
# Skip: git commit --no-verify
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
MSG_FILE="$1"
|
||||
SUBJECT=$(head -1 "$MSG_FILE")
|
||||
|
||||
# Allow merge commits and reverts untouched
|
||||
if [[ "$SUBJECT" =~ ^Merge\ branch ]] || [[ "$SUBJECT" =~ ^Revert\ \" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Conventional Commit regex ────────────────────────────────────────────────
|
||||
# type(optional-scope)optional-!: description
|
||||
TYPES="feat|fix|refactor|perf|style|test|docs|build|ops|chore|ci|revert"
|
||||
PATTERN="^(${TYPES})(\([a-zA-Z0-9._-]{1,30}\))?\!?: .{1,100}$"
|
||||
|
||||
if [[ "$SUBJECT" =~ $PATTERN ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Failure output ───────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
printf " ${RED}${BOLD}Invalid commit message${NC}\n"
|
||||
printf " ${DIM}────────────────────────────────────────────${NC}\n"
|
||||
printf " ${DIM}Got:${NC} %s\n" "$SUBJECT"
|
||||
echo ""
|
||||
printf " ${BOLD}Expected format:${NC}\n"
|
||||
printf " ${CYAN}type${NC}${DIM}(scope)${NC}${DIM}!${NC}: ${CYAN}description${NC}\n"
|
||||
echo ""
|
||||
printf " ${BOLD}Types:${NC} ${DIM}%s${NC}\n" "$TYPES"
|
||||
echo ""
|
||||
printf " ${BOLD}Examples:${NC}\n"
|
||||
printf " feat(memory): add KV store subsystem\n"
|
||||
printf " fix: prevent nil pointer in session cleanup\n"
|
||||
printf " refactor(agent)!: replace provider interface\n"
|
||||
printf " docs: update roadmap with completed milestones\n"
|
||||
echo ""
|
||||
printf " ${BOLD}Rules:${NC}\n"
|
||||
printf " ${DIM}•${NC} Type is required (see list above)\n"
|
||||
printf " ${DIM}•${NC} Scope is optional, max 30 chars, in parentheses\n"
|
||||
printf " ${DIM}•${NC} Description: imperative, lowercase start, no period\n"
|
||||
printf " ${DIM}•${NC} Max subject line: 100 characters\n"
|
||||
printf " ${DIM}•${NC} Add ${BOLD}!${NC} before ${BOLD}:${NC} for breaking changes\n"
|
||||
echo ""
|
||||
printf " ${DIM}Skip: git commit --no-verify${NC}\n"
|
||||
echo ""
|
||||
|
||||
exit 1
|
||||
200
scripts/hooks/pre-commit
Executable file
200
scripts/hooks/pre-commit
Executable file
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# picoclaw pre-commit hook
|
||||
#
|
||||
# Runs formatting, linting, build, and test checks on staged Go code.
|
||||
# Install: make hooks
|
||||
# Skip: git commit --no-verify
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Theme ────────────────────────────────────────────────────────────────────
|
||||
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASS="${GREEN}✓${NC}"
|
||||
FAIL="${RED}✗${NC}"
|
||||
SKIP="${DIM}○${NC}"
|
||||
SPIN_CHARS='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||
|
||||
WIDTH=58
|
||||
DIVIDER=$(printf '─%.0s' $(seq 1 $WIDTH))
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
elapsed() {
|
||||
local ms=$1
|
||||
if (( ms >= 1000 )); then
|
||||
printf "%d.%ds" $((ms / 1000)) $(( (ms % 1000) / 100 ))
|
||||
else
|
||||
printf "%dms" "$ms"
|
||||
fi
|
||||
}
|
||||
|
||||
now_ms() {
|
||||
# Millisecond timestamp (POSIX-ish)
|
||||
if command -v date >/dev/null && date +%s%N >/dev/null 2>&1; then
|
||||
echo $(( $(date +%s%N) / 1000000 ))
|
||||
else
|
||||
echo $(( $(date +%s) * 1000 ))
|
||||
fi
|
||||
}
|
||||
|
||||
# Print a step result line: icon label ...dots... time
|
||||
print_result() {
|
||||
local icon="$1" label="$2" time_str="$3"
|
||||
local pad_len=$(( WIDTH - ${#label} - ${#time_str} - 6 ))
|
||||
local dots=""
|
||||
if (( pad_len > 0 )); then
|
||||
dots=$(printf '.%.0s' $(seq 1 $pad_len))
|
||||
fi
|
||||
printf " %b ${BOLD}%s${NC} ${DIM}%s${NC} %s\n" "$icon" "$label" "$dots" "$time_str"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local label="$1"
|
||||
shift
|
||||
local start
|
||||
start=$(now_ms)
|
||||
|
||||
local output
|
||||
local exit_code=0
|
||||
output=$("$@" 2>&1) || exit_code=$?
|
||||
|
||||
local end
|
||||
end=$(now_ms)
|
||||
local dur=$(( end - start ))
|
||||
local time_str
|
||||
time_str=$(elapsed "$dur")
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
print_result "$PASS" "$label" "$time_str"
|
||||
STEP_TIMES+=("$dur")
|
||||
return 0
|
||||
else
|
||||
print_result "$FAIL" "$label" "$time_str"
|
||||
STEP_TIMES+=("$dur")
|
||||
if [[ -n "$output" ]]; then
|
||||
echo ""
|
||||
printf "${DIM}%s${NC}\n" "$DIVIDER"
|
||||
echo "$output" | head -30
|
||||
local lines
|
||||
lines=$(echo "$output" | wc -l)
|
||||
if (( lines > 30 )); then
|
||||
printf "${DIM} ... %d more lines (run manually to see full output)${NC}\n" $((lines - 30))
|
||||
fi
|
||||
printf "${DIM}%s${NC}\n" "$DIVIDER"
|
||||
echo ""
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Detect staged Go files ───────────────────────────────────────────────────
|
||||
|
||||
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACMR -- '*.go' || true)
|
||||
|
||||
if [[ -z "$STAGED_GO_FILES" ]]; then
|
||||
printf "\n ${DIM}No staged .go files — skipping pre-commit checks.${NC}\n\n"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STAGED_COUNT=$(echo "$STAGED_GO_FILES" | wc -l | tr -d ' ')
|
||||
STAGED_PKGS=$(echo "$STAGED_GO_FILES" | xargs -I{} dirname {} | sort -u | sed 's|^|./|')
|
||||
|
||||
# ── Banner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
TOTAL_START=$(now_ms)
|
||||
STEP_TIMES=()
|
||||
FAILED=0
|
||||
|
||||
echo ""
|
||||
printf " ${CYAN}${BOLD}picoclaw${NC} ${DIM}pre-commit${NC} ${DIM}(%d file%s)${NC}\n" \
|
||||
"$STAGED_COUNT" "$([ "$STAGED_COUNT" -eq 1 ] && echo '' || echo 's')"
|
||||
printf " ${DIM}%s${NC}\n" "$DIVIDER"
|
||||
|
||||
# ── Step 1: Format ───────────────────────────────────────────────────────────
|
||||
|
||||
check_format() {
|
||||
local unformatted
|
||||
unformatted=$(gofmt -l $STAGED_GO_FILES 2>/dev/null || true)
|
||||
if [[ -n "$unformatted" ]]; then
|
||||
echo "Unformatted files (run 'go fmt ./...' or 'make fmt'):"
|
||||
echo "$unformatted" | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_step "Format" check_format || FAILED=1
|
||||
|
||||
# ── Step 2: Vet ─────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
run_step "Lint (go vet)" go vet ./... || FAILED=1
|
||||
else
|
||||
print_result "$SKIP" "Lint (go vet)" "skipped"
|
||||
fi
|
||||
|
||||
# ── Step 3: Build ────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
run_step "Build" go build ./... || FAILED=1
|
||||
else
|
||||
print_result "$SKIP" "Build" "skipped"
|
||||
fi
|
||||
|
||||
# ── Step 4: Test (staged packages only) ──────────────────────────────────────
|
||||
|
||||
run_tests() {
|
||||
# Only test packages that have staged changes — keeps it fast.
|
||||
# Fallback to ./... if package detection fails.
|
||||
local pkgs="$STAGED_PKGS"
|
||||
if [[ -z "$pkgs" ]]; then
|
||||
pkgs="./..."
|
||||
fi
|
||||
|
||||
# Filter to packages that actually have _test.go files
|
||||
local testable=""
|
||||
for pkg in $pkgs; do
|
||||
if ls "${pkg}"/*_test.go >/dev/null 2>&1; then
|
||||
testable="$testable $pkg"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$testable" ]]; then
|
||||
echo "(no test files in staged packages)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
go test -short -count=1 -timeout 60s $testable
|
||||
}
|
||||
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
run_step "Test" run_tests || FAILED=1
|
||||
else
|
||||
print_result "$SKIP" "Test" "skipped"
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
TOTAL_END=$(now_ms)
|
||||
TOTAL_DUR=$(( TOTAL_END - TOTAL_START ))
|
||||
TOTAL_STR=$(elapsed "$TOTAL_DUR")
|
||||
|
||||
printf " ${DIM}%s${NC}\n" "$DIVIDER"
|
||||
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
printf " ${GREEN}${BOLD}All checks passed${NC} ${DIM}%s${NC}\n\n" "$TOTAL_STR"
|
||||
exit 0
|
||||
else
|
||||
printf " ${RED}${BOLD}Commit blocked${NC} ${DIM}%s${NC}\n" "$TOTAL_STR"
|
||||
printf " ${DIM}Fix errors above, or skip: ${NC}git commit --no-verify\n\n"
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Add table
Reference in a new issue