feat: add pre-commit hook support to GitHub MCP commit tool

- Execute .git/hooks/pre-commit before creating commits via GitHub API
- Add noVerify parameter to skip hooks (like git commit --no-verify)
- Handle hook failures by preventing commit creation
- Set proper Git environment variables for hook execution

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Lloyd
2025-08-19 09:42:47 -07:00
committed by Ashwin Bhat
parent e9ad08ee09
commit 1e24c646ef

View File

@@ -3,13 +3,17 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { readFile, stat } from "fs/promises"; import { readFile, access, stat } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { constants } from "fs"; import { constants } from "fs";
import { execFile } from "child_process";
import { promisify } from "util";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { retryWithBackoff } from "../utils/retry"; import { retryWithBackoff } from "../utils/retry";
const execFileAsync = promisify(execFile);
type GitHubRef = { type GitHubRef = {
object: { object: {
sha: string; sha: string;
@@ -192,6 +196,48 @@ async function getFileMode(filePath: string): Promise<string> {
} }
} }
// Helper function to run pre-commit hooks
async function runPreCommitHooks(repoDir: string): Promise<void> {
const hookPath = join(repoDir, ".git", "hooks", "pre-commit");
try {
// Check if pre-commit hook exists and is executable
await access(hookPath);
console.log("Running pre-commit hook...");
// Execute the pre-commit hook
const { stdout, stderr } = await execFileAsync(hookPath, [], {
cwd: repoDir,
env: {
...process.env,
GIT_INDEX_FILE: join(repoDir, ".git", "index"),
GIT_DIR: join(repoDir, ".git"),
},
});
if (stdout) console.log("Pre-commit hook stdout:", stdout);
if (stderr) console.log("Pre-commit hook stderr:", stderr);
console.log("Pre-commit hook passed");
} catch (error: any) {
if (error.code === "ENOENT") {
// Hook doesn't exist, that's fine
return;
}
if (error.code === "EACCES") {
console.log("Pre-commit hook exists but is not executable, skipping");
return;
}
// Hook failed with non-zero exit code
const errorMessage =
error.stderr || error.message || "Pre-commit hook failed";
throw new Error(`Pre-commit hook failed: ${errorMessage}`);
}
}
// Commit files tool // Commit files tool
server.tool( server.tool(
"commit_files", "commit_files",
@@ -203,8 +249,12 @@ server.tool(
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.', 'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
), ),
message: z.string().describe("Commit message"), message: z.string().describe("Commit message"),
noVerify: z
.boolean()
.optional()
.describe("Skip pre-commit hooks (equivalent to git commit --no-verify)"),
}, },
async ({ files, message }) => { async ({ files, message, noVerify }) => {
const owner = REPO_OWNER; const owner = REPO_OWNER;
const repo = REPO_NAME; const repo = REPO_NAME;
const branch = BRANCH_NAME; const branch = BRANCH_NAME;
@@ -214,6 +264,11 @@ server.tool(
throw new Error("GITHUB_TOKEN environment variable is required"); throw new Error("GITHUB_TOKEN environment variable is required");
} }
// Run pre-commit hooks unless explicitly skipped
if (!noVerify) {
await runPreCommitHooks(REPO_DIR);
}
const processedFiles = files.map((filePath) => { const processedFiles = files.map((filePath) => {
if (filePath.startsWith("/")) { if (filePath.startsWith("/")) {
return filePath.slice(1); return filePath.slice(1);