Commit 7aea508579dc
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/pre-commit.ts
@@ -1,298 +0,0 @@
-/**
- * Pi Extension: Pre-commit Linting and Formatting
- *
- * Intercepts git commit commands and runs appropriate linters/formatters
- * before allowing the commit. Detects project type and runs the right tools.
- *
- * Detection and tools:
- * - Nix files (.nix): nixfmt, deadnix, statix
- * - Go files (.go): gofmt, go vet
- * - Python files (.py): ruff format, ruff check
- * - TypeScript/JavaScript: prettier
- * - Shell scripts (.sh): shellcheck
- *
- * Also checks for:
- * - pre-commit hooks (runs `pre-commit run --files` if .pre-commit-config.yaml exists)
- * - Makefile targets (runs `make fmt` or `make format` if available)
- *
- * The extension only formats staged files, not the entire project.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { execSync, spawnSync } from "node:child_process";
-import { existsSync, readFileSync } from "node:fs";
-import { join, extname } from "node:path";
-
-// Check if a command exists
-function commandExists(cmd: string): boolean {
- try {
- execSync(`which ${cmd}`, { stdio: "ignore" });
- return true;
- } catch {
- return false;
- }
-}
-
-// Get staged files from git
-function getStagedFiles(): string[] {
- try {
- const output = execSync("git diff --cached --name-only --diff-filter=ACMR", {
- encoding: "utf-8",
- });
- return output.trim().split("\n").filter(Boolean);
- } catch {
- return [];
- }
-}
-
-// Group files by extension/type
-function groupFilesByType(files: string[]): Map<string, string[]> {
- const groups = new Map<string, string[]>();
-
- for (const file of files) {
- const ext = extname(file).toLowerCase();
- let type: string;
-
- switch (ext) {
- case ".nix":
- type = "nix";
- break;
- case ".go":
- type = "go";
- break;
- case ".py":
- type = "python";
- break;
- case ".ts":
- case ".tsx":
- case ".js":
- case ".jsx":
- case ".json":
- case ".md":
- case ".yaml":
- case ".yml":
- type = "prettier";
- break;
- case ".sh":
- case ".bash":
- type = "shell";
- break;
- case ".el":
- type = "elisp";
- break;
- default:
- continue;
- }
-
- if (!groups.has(type)) {
- groups.set(type, []);
- }
- groups.get(type)!.push(file);
- }
-
- return groups;
-}
-
-// Run a formatter/linter on files
-function runTool(
- cmd: string,
- args: string[],
- files: string[],
- cwd: string
-): { success: boolean; output: string } {
- if (files.length === 0) {
- return { success: true, output: "" };
- }
-
- const result = spawnSync(cmd, [...args, ...files], {
- cwd,
- encoding: "utf-8",
- stdio: ["pipe", "pipe", "pipe"],
- });
-
- const output = (result.stdout || "") + (result.stderr || "");
- return {
- success: result.status === 0,
- output: output.trim(),
- };
-}
-
-// Check if pre-commit is available and configured
-function hasPreCommitConfig(cwd: string): boolean {
- return existsSync(join(cwd, ".pre-commit-config.yaml"));
-}
-
-// Check if Makefile has format target
-function hasMakeFormatTarget(cwd: string): boolean {
- const makefilePath = join(cwd, "Makefile");
- if (!existsSync(makefilePath)) return false;
-
- try {
- const content = readFileSync(makefilePath, "utf-8");
- return /^(fmt|format):/m.test(content);
- } catch {
- return false;
- }
-}
-
-export default function (pi: ExtensionAPI) {
- // Intercept git commit commands
- pi.on("tool_call", async (event, ctx) => {
- if (event.toolName.toLowerCase() !== "bash") {
- return undefined;
- }
-
- const command = event.input?.command;
- if (!command || typeof command !== "string") {
- return undefined;
- }
-
- // Check if this is a git commit command
- const gitCommitPattern = /(^|&&|\|\||;|\||\$\()\s*git\s+commit(\s|$)/;
- if (!gitCommitPattern.test(command)) {
- return undefined;
- }
-
- // Skip if --no-verify flag is present (user explicitly wants to skip hooks)
- if (/--no-verify/.test(command)) {
- ctx.ui.notify("[pre-commit] Skipping checks (--no-verify)", "warning");
- return undefined;
- }
-
- const cwd = process.cwd();
- const stagedFiles = getStagedFiles();
-
- if (stagedFiles.length === 0) {
- return undefined; // No staged files, let git handle it
- }
-
- ctx.ui.notify(`[pre-commit] Checking ${stagedFiles.length} staged files...`, "info");
-
- const errors: string[] = [];
- const formatted: string[] = [];
-
- // If pre-commit is configured, use it
- if (hasPreCommitConfig(cwd) && commandExists("pre-commit")) {
- ctx.ui.notify("[pre-commit] Running pre-commit hooks...", "info");
- const result = runTool("pre-commit", ["run", "--files"], stagedFiles, cwd);
- if (!result.success) {
- errors.push(`pre-commit failed:\n${result.output}`);
- } else {
- formatted.push("pre-commit hooks passed");
- }
- } else {
- // Run individual formatters based on file types
- const fileGroups = groupFilesByType(stagedFiles);
-
- // Nix files
- const nixFiles = fileGroups.get("nix") || [];
- if (nixFiles.length > 0) {
- if (commandExists("nixfmt")) {
- const result = runTool("nixfmt", ["--check"], nixFiles, cwd);
- if (!result.success) {
- // Try to format
- const fmtResult = runTool("nixfmt", [], nixFiles, cwd);
- if (fmtResult.success) {
- formatted.push(`nixfmt: formatted ${nixFiles.length} files`);
- } else {
- errors.push(`nixfmt failed:\n${fmtResult.output}`);
- }
- }
- }
- if (commandExists("deadnix")) {
- const result = runTool("deadnix", ["--fail"], nixFiles, cwd);
- if (!result.success) {
- errors.push(`deadnix found issues:\n${result.output}`);
- }
- }
- }
-
- // Go files
- const goFiles = fileGroups.get("go") || [];
- if (goFiles.length > 0 && commandExists("gofmt")) {
- const result = runTool("gofmt", ["-l"], goFiles, cwd);
- if (result.output) {
- // Files need formatting
- const fmtResult = runTool("gofmt", ["-w"], goFiles, cwd);
- if (fmtResult.success) {
- formatted.push(`gofmt: formatted ${goFiles.length} files`);
- } else {
- errors.push(`gofmt failed:\n${fmtResult.output}`);
- }
- }
- }
-
- // Python files
- const pyFiles = fileGroups.get("python") || [];
- if (pyFiles.length > 0 && commandExists("ruff")) {
- // Format first
- const fmtResult = runTool("ruff", ["format"], pyFiles, cwd);
- if (fmtResult.success) {
- formatted.push(`ruff format: ${pyFiles.length} files`);
- }
- // Then check
- const checkResult = runTool("ruff", ["check", "--fix"], pyFiles, cwd);
- if (!checkResult.success) {
- errors.push(`ruff check found issues:\n${checkResult.output}`);
- }
- }
-
- // Prettier files (JS/TS/JSON/MD/YAML)
- const prettierFiles = fileGroups.get("prettier") || [];
- if (prettierFiles.length > 0 && commandExists("prettier")) {
- const result = runTool("prettier", ["--write"], prettierFiles, cwd);
- if (result.success) {
- formatted.push(`prettier: formatted ${prettierFiles.length} files`);
- } else {
- errors.push(`prettier failed:\n${result.output}`);
- }
- }
-
- // Shell files
- const shellFiles = fileGroups.get("shell") || [];
- if (shellFiles.length > 0 && commandExists("shellcheck")) {
- const result = runTool("shellcheck", [], shellFiles, cwd);
- if (!result.success) {
- errors.push(`shellcheck found issues:\n${result.output}`);
- }
- }
- }
-
- // Report results
- if (formatted.length > 0) {
- ctx.ui.notify(`[pre-commit] Formatted: ${formatted.join(", ")}`, "info");
- // Re-stage formatted files
- try {
- execSync(`git add ${stagedFiles.join(" ")}`, { cwd, stdio: "ignore" });
- ctx.ui.notify("[pre-commit] Re-staged formatted files", "info");
- } catch {
- ctx.ui.notify("[pre-commit] Warning: Could not re-stage files", "warning");
- }
- }
-
- if (errors.length > 0) {
- const errorMessage = [
- "BLOCKED: Pre-commit checks failed!",
- "",
- ...errors,
- "",
- "Fix the issues above and try again.",
- "Use --no-verify to skip checks (not recommended).",
- ].join("\n");
-
- ctx.ui.notify(errorMessage, "error");
-
- return {
- block: true,
- reason: errorMessage,
- };
- }
-
- ctx.ui.notify("[pre-commit] All checks passed โ", "info");
- return undefined; // Allow the commit
- });
-
- pi.on("session_start", (_event, ctx) => {
- ctx.ui.notify("Pre-commit linting active", "info");
- });
-}