gitingest-mcp
Version:
MCP server for transforming Git repositories into LLM-friendly text digests
218 lines (186 loc) • 5.84 kB
text/typescript
import { spawn } from "child_process";
import { promises as fs } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";
export interface GitCloneOptions {
url: string;
branch?: string;
commit?: string;
tag?: string;
depth?: number;
sparse?: boolean;
subpath?: string;
includeSubmodules?: boolean;
}
export interface GitCloneResult {
path: string;
branch: string;
commit: string;
isShallow: boolean;
}
export class GitCloneTool {
private static readonly DEFAULT_DEPTH = 1;
private static readonly MAX_DEPTH = 1000;
static async clone(options: GitCloneOptions, signal?: AbortSignal): Promise<GitCloneResult> {
const {
url,
branch,
commit,
tag,
depth = this.DEFAULT_DEPTH,
sparse = false,
subpath,
includeSubmodules = false,
} = options;
// Create temporary directory
const tempDir = await this.createTempDir();
try {
// Clone repository
const cloneArgs = ["clone"];
// Add branch/tag/commit
if (branch) {
cloneArgs.push("--branch", branch);
} else if (tag) {
cloneArgs.push("--branch", tag);
}
// Add depth
if (depth && depth > 0 && depth <= this.MAX_DEPTH) {
cloneArgs.push("--depth", depth.toString());
}
// Add sparse checkout
if (sparse && subpath) {
cloneArgs.push("--filter=blob:none", "--sparse");
}
// Add submodules
if (includeSubmodules) {
cloneArgs.push("--recurse-submodules");
}
cloneArgs.push(url, tempDir);
await this.executeGitCommand(cloneArgs);
// If specific commit is provided, checkout it
if (commit) {
await this.executeGitCommand(["checkout", commit], { cwd: tempDir });
}
// If sparse checkout with subpath
if (sparse && subpath) {
await this.executeGitCommand(["sparse-checkout", "set", subpath], { cwd: tempDir });
}
// Get actual branch and commit
const branchName = await this.getCurrentBranch(tempDir);
const commitHash = await this.getCurrentCommit(tempDir);
return {
path: tempDir,
branch: branchName,
commit: commitHash,
isShallow: depth !== undefined && depth > 0,
};
} catch (error) {
// Clean up on error
await this.cleanup(tempDir);
throw error;
}
}
static async getCurrentBranch(repoPath: string): Promise<string> {
try {
const output = await this.executeGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath });
return output.trim();
} catch {
return "main";
}
}
static async getCurrentCommit(repoPath: string): Promise<string> {
try {
const output = await this.executeGitCommand(["rev-parse", "HEAD"], { cwd: repoPath });
return output.trim();
} catch {
return "unknown";
}
}
static async getBranches(repoPath: string, remote = true): Promise<string[]> {
try {
const args = remote ? ["branch", "-r"] : ["branch", "-l"];
const output = await this.executeGitCommand(args, { cwd: repoPath });
return output
.split("\n")
.map(branch => branch.trim().replace(/^origin\//, ""))
.filter(branch => branch && !branch.includes("HEAD"));
} catch {
return [];
}
}
static async getTags(repoPath: string): Promise<string[]> {
try {
const output = await this.executeGitCommand(["tag", "-l"], { cwd: repoPath });
return output.split("\n").filter(tag => tag.trim());
} catch {
return [];
}
}
static async getCommits(repoPath: string, maxCount = 10): Promise<Array<{ hash: string; message: string; author: string; date: string }>> {
try {
const format = "%H|%s|%an|%ad";
const output = await this.executeGitCommand(
["log", `--format=${format}`, `--max-count=${maxCount}`],
{ cwd: repoPath }
);
return output
.split("\n")
.filter(line => line.trim())
.map(line => {
const [hash, message, author, date] = line.split("|");
return { hash, message, author, date };
});
} catch {
return [];
}
}
private static async createTempDir(): Promise<string> {
const tempBase = tmpdir();
const tempDir = join(tempBase, `gitingest-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
await fs.mkdir(tempDir, { recursive: true });
return tempDir;
}
private static async executeGitCommand(args: string[], options: { cwd?: string, signal?: AbortSignal } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const git = spawn("git", args, {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
signal: options.signal,
});
let stdout = "";
let stderr = "";
git.stdout.on("data", (data) => {
stdout += data.toString();
});
git.stderr.on("data", (data) => {
stderr += data.toString();
});
git.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`Git command failed: ${stderr || stdout}`));
}
});
git.on("error", (error) => {
reject(new Error(`Failed to execute git: ${error.message}`));
});
});
}
static async cleanup(repoPath: string): Promise<void> {
try {
await fs.rm(repoPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
static async isGitAvailable(): Promise<boolean> {
try {
await this.executeGitCommand(["--version"]);
return true;
} catch {
return false;
}
}
}