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:
ZanzyTHEbar 2026-02-19 13:29:03 +00:00
parent f4ec7103fe
commit 3186c2d216
4 changed files with 288 additions and 8 deletions

View file

@ -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 fantasy-check fantasy-diff fantasy-sync fantasy-patch
# Build variables # Build variables
@ -127,7 +127,7 @@ clean:
vet: vet:
@$(GO) vet ./... @$(GO) vet ./...
## fmt: Format Go code ## test: Run tests
test: test:
@$(GO) test ./... @$(GO) test ./...
@ -135,6 +135,21 @@ test:
fmt: fmt:
@$(GO) 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: Download dependencies
deps: deps:
@$(GO) mod download @$(GO) mod download

65
scripts/hooks/commit-msg Executable file
View 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
View 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