Commit d5a3a6b0246c

Vincent Demeester <vincent@sbr.pm>
2026-02-06 09:30:31
feat(pi): migrate validate-git-push hook to TypeScript
Ported Go-based claude-hooks-validate-git-push to standalone TypeScript extension. Runs natively in Pi without subprocess overhead. Blocks git push without explicit refspec and dangerous git add commands (-A, --all, .).
1 parent 4101ea7
Changed files (2)
dots/pi/agent/extensions/claude-hooks.ts
@@ -124,26 +124,8 @@ export default function (pi: ExtensionAPI) {
     sessionInitialized = false;
   });
 
-  // Pre-tool-use validation (can block)
-  pi.on("tool_call", async (event, _ctx) => {
-    // Run validate-git-push for bash commands
-    if (
-      event.toolName.toLowerCase() === "bash" &&
-      binaryExists("claude-hooks-validate-git-push")
-    ) {
-      const jsonInput = toClaudePreToolUse(event);
-      const result = await runHook("claude-hooks-validate-git-push", jsonInput);
-
-      if (result.exitCode !== 0) {
-        return {
-          block: true,
-          reason: result.stderr.trim() || "Command blocked by validate-git-push hook",
-        };
-      }
-    }
-
-    return undefined;
-  });
+  // Pre-tool-use validation is now handled by validate-git-push.ts extension
+  // No need to call Go binary here anymore
 
   // Post-tool-use capture
   pi.on("tool_result", async (event, _ctx) => {
dots/pi/agent/extensions/validate-git-push.ts
@@ -0,0 +1,144 @@
+/**
+ * Pi Extension: Git Push Validation
+ *
+ * Validates git commands to prevent common mistakes:
+ * - Blocks git push without explicit refspec (branch:branch)
+ * - Blocks dangerous git add commands (-A, --all, .)
+ * - Warns about pushing to protected branches (main/master)
+ *
+ * This is a TypeScript migration of the Go-based claude-hooks-validate-git-push.
+ *
+ * Safety rules:
+ * 1. Always use explicit refspec: git push origin branch:branch
+ * 2. Never use git add -A, git add --all, or git add .
+ * 3. Use specific file paths for git add
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+/**
+ * Check if a git push command uses explicit refspec (branch:branch)
+ */
+function hasExplicitRefspec(command: string): boolean {
+  // Match patterns like:
+  // git push origin branch:branch
+  // git push origin HEAD:branch
+  // git push -u origin branch:branch
+  // git push --force-with-lease origin branch:branch
+  const refspecPattern = /git\s+push\s+.*\s+\S+:\S+/;
+  return refspecPattern.test(command);
+}
+
+/**
+ * Check if this is a dangerous git add command
+ */
+function isDangerousGitAdd(command: string): boolean {
+  // Match patterns like:
+  // git add -A
+  // git add --all
+  // git add .
+  const dangerousAddPatterns = [
+    /(^|&&|\|\||;|\||\$\()\s*git\s+add\s+-A(\s|$)/,
+    /(^|&&|\|\||;|\||\$\()\s*git\s+add\s+--all(\s|$)/,
+    /(^|&&|\|\||;|\||\$\()\s*git\s+add\s+\.(\s|$)/,
+  ];
+
+  return dangerousAddPatterns.some((pattern) => pattern.test(command));
+}
+
+/**
+ * Check if this is a git push command (not just the string "git push" inside arguments)
+ */
+function isGitPush(command: string): boolean {
+  // Match git push only when it appears as an actual command:
+  // - At the start of the command
+  // - After command separators: && || ; |
+  // - After $( for command substitution
+  // This avoids false positives on "git push" inside heredocs, strings, or commit messages
+  const gitPushPattern = /(^|&&|\|\||;|\||\$\()\s*git\s+push(\s|$)/;
+  return gitPushPattern.test(command);
+}
+
+/**
+ * Check if pushing to a protected branch without explicit refspec
+ */
+function isPushToProtectedBranch(command: string): boolean {
+  // These patterns indicate pushing to main/master without explicit refspec
+  const protectedBranches = [":main", ":master"];
+  return protectedBranches.some((branch) => command.includes(branch));
+}
+
+export default function (pi: ExtensionAPI) {
+  // Pre-tool-use validation (can block)
+  pi.on("tool_call", async (event, ctx) => {
+    // Only check bash commands
+    if (event.toolName.toLowerCase() !== "bash") {
+      return undefined;
+    }
+
+    const command = event.input?.command;
+    if (!command || typeof command !== "string") {
+      return undefined;
+    }
+
+    // Check for dangerous git add commands first
+    if (isDangerousGitAdd(command)) {
+      const errorMessage = [
+        "BLOCKED: Dangerous git add command detected!",
+        "",
+        "Commands like 'git add -A', 'git add --all', and 'git add .' can add unintended files.",
+        "",
+        "Use explicit file paths instead:",
+        "  git add path/to/specific/file.txt",
+        "  git add path/to/directory/",
+        "  git add *.go  # for specific patterns",
+        "",
+        `Blocked command: ${command}`,
+      ].join("\n");
+
+      ctx.ui.notify(errorMessage, "error");
+
+      return {
+        block: true,
+        reason: errorMessage,
+      };
+    }
+
+    // Check if this is a git push command
+    if (!isGitPush(command)) {
+      return undefined;
+    }
+
+    // Check if it has explicit refspec
+    if (!hasExplicitRefspec(command)) {
+      const errorMessage = [
+        "BLOCKED: git push without explicit refspec detected!",
+        "",
+        "The command uses implicit branch tracking which can push to wrong branches.",
+        "",
+        "Use explicit refspec instead:",
+        "  git push origin <branch>:<branch>",
+        "  git push origin HEAD:<branch>",
+        "",
+        `Blocked command: ${command}`,
+      ].join("\n");
+
+      ctx.ui.notify(errorMessage, "error");
+
+      return {
+        block: true,
+        reason: errorMessage,
+      };
+    }
+
+    // Warn about pushing to protected branches (but allow it with explicit refspec)
+    if (isPushToProtectedBranch(command)) {
+      ctx.ui.notify(
+        "[validate-git-push] Warning: Pushing to protected branch (main/master)",
+        "warning"
+      );
+    }
+
+    return undefined; // Allow the command
+  });
+}