gitingest-mcp
Version:
MCP server for transforming Git repositories into LLM-friendly text digests
234 lines • 8.9 kB
JavaScript
import { promises as fs } from "fs";
import { join, relative, resolve } from "path";
import { GitCloneTool } from "./git-clone.js";
export class LocalRepositoryTool {
static async analyze(options, signal) {
const { path, includeGitignored = false, useGitignore = true, useGitingestignore = true, maxFileSize = this.DEFAULT_MAX_FILE_SIZE, maxFiles = this.DEFAULT_MAX_FILES, excludePatterns = [], includePatterns = [], } = options;
// Resolve absolute path
const absolutePath = resolve(path);
// Check if path exists
try {
await fs.access(absolutePath);
}
catch {
throw new Error(`Local repository path does not exist: ${path}`);
}
// Get repository info
const repoInfo = await this.getRepositoryInfo(absolutePath);
// Get ignore patterns
const ignorePatterns = await this.getIgnorePatterns(absolutePath, includeGitignored, useGitignore, useGitingestignore, excludePatterns);
// Collect files
const files = await this.collectFiles(absolutePath, ignorePatterns, includePatterns, maxFileSize, maxFiles, signal);
// Build tree structure
const tree = this.buildTree(files, absolutePath);
// Calculate summary
const summary = await this.calculateSummary(absolutePath, files, repoInfo);
return {
path: absolutePath,
files,
tree,
summary,
};
}
static async getRepositoryInfo(repoPath) {
try {
const branch = await GitCloneTool.getCurrentBranch(repoPath);
const commit = await GitCloneTool.getCurrentCommit(repoPath);
return { branch, commit };
}
catch {
return { branch: "main", commit: "unknown" };
}
}
static async getIgnorePatterns(repoPath, includeGitignored, useGitignore, useGitingestignore, excludePatterns) {
const patterns = [];
// Add exclude patterns
patterns.push(...excludePatterns);
if (!includeGitignored) {
// Add .gitignore patterns
if (useGitignore) {
const gitignorePath = join(repoPath, ".gitignore");
try {
const gitignore = await fs.readFile(gitignorePath, "utf-8");
patterns.push(...this.parseIgnoreFile(gitignore));
}
catch {
// .gitignore doesn't exist, ignore
}
}
// Add .gitingestignore patterns
if (useGitingestignore) {
const gitingestignorePath = join(repoPath, ".gitingestignore");
try {
const gitingestignore = await fs.readFile(gitingestignorePath, "utf-8");
patterns.push(...this.parseIgnoreFile(gitingestignore));
}
catch {
// .gitingestignore doesn't exist, ignore
}
}
}
// Always ignore .git directory
patterns.push(".git", ".git/**");
return patterns;
}
static parseIgnoreFile(content) {
return content
.split("\n")
.map(line => line.trim())
.filter(line => line && !line.startsWith("#"))
.map(line => {
// Handle negation patterns
if (line.startsWith("!")) {
return line;
}
return line;
});
}
static async collectFiles(repoPath, ignorePatterns, includePatterns, maxFileSize, maxFiles, signal) {
const files = [];
const walk = async (dir) => {
if (signal?.aborted) {
throw new Error('Operation aborted');
}
if (files.length >= maxFiles) {
return;
}
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (files.length >= maxFiles) {
break;
}
const fullPath = join(dir, entry.name);
const relativePath = relative(repoPath, fullPath);
// Check ignore patterns
if (this.shouldIgnore(relativePath, ignorePatterns)) {
continue;
}
// Check include patterns
if (includePatterns.length > 0 && !this.shouldInclude(relativePath, includePatterns)) {
continue;
}
if (entry.isDirectory()) {
await walk(fullPath);
}
else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
if (stats.size > maxFileSize) {
continue;
}
try {
const content = await fs.readFile(fullPath, "utf-8");
files.push({
path: relativePath,
content,
size: stats.size,
type: "file",
});
}
catch {
// Skip binary files or files that can't be read as text
}
}
}
};
await walk(repoPath);
return files;
}
static shouldIgnore(path, patterns) {
for (const pattern of patterns) {
if (pattern.startsWith("!")) {
// Negation pattern
const negatedPattern = pattern.slice(1);
if (this.matchesPattern(path, negatedPattern)) {
return false;
}
}
else if (this.matchesPattern(path, pattern)) {
return true;
}
}
return false;
}
static shouldInclude(path, patterns) {
for (const pattern of patterns) {
if (this.matchesPattern(path, pattern)) {
return true;
}
}
return patterns.length === 0;
}
static matchesPattern(path, pattern) {
// Simple glob matching
const regex = pattern
.replace(/\*\*/g, ".*")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]");
return new RegExp(`^${regex}$`).test(path);
}
static buildTree(files, repoPath) {
const root = {
name: "",
type: "directory",
children: [],
};
for (const file of files) {
const parts = file.path.split("/");
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
if (isFile) {
current.children = current.children || [];
current.children.push({
name: part,
type: "file",
size: file.size,
});
}
else {
let dir = current.children?.find(child => child.name === part && child.type === "directory");
if (!dir) {
dir = {
name: part,
type: "directory",
children: [],
};
current.children = current.children || [];
current.children.push(dir);
}
current = dir;
}
}
}
return root;
}
static async calculateSummary(repoPath, files, repoInfo) {
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const tokenCount = files.reduce((sum, file) => sum + this.estimateTokens(file.content), 0);
const directories = new Set();
for (const file of files) {
const parts = file.path.split("/");
for (let i = 0; i < parts.length - 1; i++) {
directories.add(parts.slice(0, i + 1).join("/"));
}
}
return {
path: repoPath,
branch: repoInfo.branch,
commit: repoInfo.commit,
fileCount: files.length,
directoryCount: directories.size,
totalSize,
tokenCount,
createdAt: new Date().toISOString(),
};
}
static estimateTokens(content) {
// Rough estimation: 1 token ≈ 4 characters
return Math.ceil(content.length / 4);
}
}
LocalRepositoryTool.DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
LocalRepositoryTool.DEFAULT_MAX_FILES = 1000;
//# sourceMappingURL=local-repository.js.map