From 3186c2d216835508614344274c53d922e3bc6e1a Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Thu, 19 Feb 2026 13:29:03 +0000 Subject: [PATCH] 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. --- Makefile | 19 +++- pkg/memory/dag/node.go | 12 +-- scripts/hooks/commit-msg | 65 +++++++++++++ scripts/hooks/pre-commit | 200 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 8 deletions(-) create mode 100755 scripts/hooks/commit-msg create mode 100755 scripts/hooks/pre-commit diff --git a/Makefile b/Makefile index a853de0f0..e87c4994e 100644 --- a/Makefile +++ b/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 diff --git a/pkg/memory/dag/node.go b/pkg/memory/dag/node.go index df5399c66..f886a7121 100644 --- a/pkg/memory/dag/node.go +++ b/pkg/memory/dag/node.go @@ -34,12 +34,12 @@ func (l Level) String() string { // extractive summary and retains lossless pointers back to the // original message range it covers. type Node struct { - ID string `json:"id"` - Level Level `json:"level"` - Summary string `json:"summary"` - Tokens int `json:"tokens"` - StartIdx int `json:"start_idx"` // Inclusive index into original message slice - EndIdx int `json:"end_idx"` // Exclusive index into original message slice + ID string `json:"id"` + Level Level `json:"level"` + Summary string `json:"summary"` + Tokens int `json:"tokens"` + StartIdx int `json:"start_idx"` // Inclusive index into original message slice + EndIdx int `json:"end_idx"` // Exclusive index into original message slice Children []string `json:"children,omitempty"` // Child node IDs (lower level) } diff --git a/scripts/hooks/commit-msg b/scripts/hooks/commit-msg new file mode 100755 index 000000000..2d2ec1f69 --- /dev/null +++ b/scripts/hooks/commit-msg @@ -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 diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 000000000..d33671771 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -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