/** * pi-git-hooks — Git Hook Manager * Install, list, and manage git hooks for any project. * * /hooks list → list installed hooks * /hooks install → install a hook template * /hooks run → manually run a hook * /hooks templates → show available templates */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { platform } from "node:os"; const RST = "\x1b[0m", B = "\x1b[1m", D = "\x1b[2m"; const G = "\x1b[32m", R = "\x1b[31m", Y = "\x1b[33m", C = "\x1b[36m"; const HOOK_TYPES = ["pre-commit", "commit-msg", "pre-push", "post-merge", "post-checkout", "prepare-commit-msg"]; const TEMPLATES: Record = { "pre-commit-lint": { desc: "Run linter before commit", script: `#!/bin/sh\nnpx --no-install eslint --max-warnings 0 . 2>/dev/null || { echo "Lint failed"; exit 1; }\n`, }, "pre-commit-format": { desc: "Check formatting before commit", script: `#!/bin/sh\nnpx --no-install prettier --check . 2>/dev/null || { echo "Format check failed. Run: npx prettier --write ."; exit 1; }\n`, }, "pre-commit-test": { desc: "Run tests before commit", script: `#!/bin/sh\nnpm test 2>/dev/null || { echo "Tests failed"; exit 1; }\n`, }, "pre-commit-typecheck": { desc: "TypeScript type check before commit", script: `#!/bin/sh\nnpx --no-install tsc --noEmit 2>/dev/null || { echo "Type errors found"; exit 1; }\n`, }, "commit-msg-conventional": { desc: "Enforce conventional commit messages (feat/fix/chore/...)", script: `#!/bin/sh\nMSG=$(cat "$1")\nif ! echo "$MSG" | grep -qE "^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\\(.+\\))?: .+"; then\n echo "Commit message must follow conventional format:"\n echo " feat: add new feature"\n echo " fix: resolve bug"\n echo " chore: maintenance task"\n exit 1\nfi\n`, }, "commit-msg-length": { desc: "Enforce commit message length (max 72 chars first line)", script: `#!/bin/sh\nFIRST=$(head -1 "$1")\nif [ \${#FIRST} -gt 72 ]; then\n echo "First line of commit message must be <= 72 chars (got \${#FIRST})"\n exit 1\nfi\n`, }, "pre-push-test": { desc: "Run full test suite before push", script: `#!/bin/sh\nnpm test 2>/dev/null || { echo "Tests failed — push aborted"; exit 1; }\n`, }, "pre-push-build": { desc: "Verify build succeeds before push", script: `#!/bin/sh\nnpm run build 2>/dev/null || { echo "Build failed — push aborted"; exit 1; }\n`, }, }; function findGitDir(dir = "."): string | null { try { const out = execSync("git rev-parse --git-dir", { encoding: "utf-8", cwd: dir, timeout: 5000 }).trim(); return out; } catch { return null; } } function listHooks(gitDir: string): { name: string; active: boolean; size: number }[] { const hooksDir = join(gitDir, "hooks"); if (!existsSync(hooksDir)) return []; return readdirSync(hooksDir) .filter(f => !f.endsWith(".sample")) .map(f => { const content = readFileSync(join(hooksDir, f), "utf-8"); return { name: f, active: content.trim().length > 0, size: content.length }; }); } export default function (pi: ExtensionAPI) { pi.registerCommand("hooks", { description: "Git hooks: /hooks list|install|run|templates", handler: async (args, ctx) => { const parts = (args || "").trim().split(/\s+/); const cmd = parts[0] || "list"; const gitDir = findGitDir(); if (!gitDir && cmd !== "templates") return `${R}Not in a git repository.${RST}`; if (cmd === "list") { const hooks = listHooks(gitDir!); if (!hooks.length) return `${Y}No hooks installed.${RST} Use /hooks install