Skip to main content
Glama
repository-detector.ts15.8 kB
/** * Repository Detector * * Utility for detecting Git repositories, extracting repository information, * and auto-detecting project context for Git operations. */ import { GitCommandExecutor } from './git-command-executor.js'; import { TerminalController } from './terminal-controller.js'; import path from 'path'; import { promises as fs } from 'fs'; export interface RepositoryInfo { isGitRepository: boolean; repositoryPath?: string; repositoryName?: string; remoteOrigin?: string; currentBranch?: string; detectedProvider?: 'github' | 'gitea' | 'unknown'; hasRemotes?: boolean; remotes?: Array<{ name: string; url: string; provider?: 'github' | 'gitea' | 'unknown'; }>; workingDirectory?: string; gitDirectory?: string; isBare?: boolean; hasUncommittedChanges?: boolean; lastCommit?: { hash: string; message: string; author: string; date: string; }; } export interface ProjectContext { projectPath: string; projectName: string; repositoryInfo: RepositoryInfo; autoDetectedSettings?: { owner?: string; repository?: string; provider?: 'github' | 'gitea'; remoteUrl?: string; }; } export class RepositoryDetector { private gitExecutor: GitCommandExecutor; private terminal: TerminalController; constructor() { this.gitExecutor = new GitCommandExecutor(); this.terminal = new TerminalController(); } /** * Detect repository information for a given path */ async detectRepository(projectPath: string): Promise<RepositoryInfo> { const resolvedPath = path.resolve(projectPath); // Check if directory exists const dirValidation = await this.terminal.validateDirectory(resolvedPath); if (!dirValidation.exists) { return { isGitRepository: false, workingDirectory: resolvedPath }; } // Check if it's a Git repository const isGitRepo = await this.gitExecutor.isGitRepository(resolvedPath); if (!isGitRepo) { return { isGitRepository: false, workingDirectory: resolvedPath }; } // Get repository path (could be different from working directory in case of subdirectories) const repoPathResult = await this.gitExecutor.executeGitCommand( 'rev-parse', ['--show-toplevel'], resolvedPath ); const repositoryPath = repoPathResult.success ? path.resolve(repoPathResult.stdout.trim()) : resolvedPath; // Get repository name const repositoryName = path.basename(repositoryPath); // Get Git directory const gitDirResult = await this.gitExecutor.executeGitCommand( 'rev-parse', ['--git-dir'], repositoryPath ); const gitDirectory = gitDirResult.success ? path.resolve(repositoryPath, gitDirResult.stdout.trim()) : path.join(repositoryPath, '.git'); // Check if bare repository const isBareResult = await this.gitExecutor.executeGitCommand( 'rev-parse', ['--is-bare-repository'], repositoryPath ); const isBare = isBareResult.success && isBareResult.stdout.trim() === 'true'; // Get current branch const branchResult = await this.gitExecutor.getCurrentBranch(repositoryPath); const currentBranch = branchResult.branch; // Get remotes const remotesInfo = await this.getRemotesInfo(repositoryPath); const remoteOrigin = remotesInfo.find(remote => remote.name === 'origin')?.url; const detectedProvider = this.detectProviderFromUrl(remoteOrigin); // Check for uncommitted changes const statusResult = await this.gitExecutor.getStatus(repositoryPath); const hasUncommittedChanges = statusResult.success && statusResult.parsedStatus && !statusResult.parsedStatus.clean; // Get last commit const lastCommit = await this.getLastCommitInfo(repositoryPath); return { isGitRepository: true, repositoryPath, repositoryName, remoteOrigin, currentBranch, detectedProvider, hasRemotes: remotesInfo.length > 0, remotes: remotesInfo, workingDirectory: resolvedPath, gitDirectory, isBare, hasUncommittedChanges, lastCommit }; } /** * Get complete project context with auto-detection */ async getProjectContext(projectPath: string): Promise<ProjectContext> { const resolvedPath = path.resolve(projectPath); const projectName = path.basename(resolvedPath); const repositoryInfo = await this.detectRepository(resolvedPath); // Auto-detect settings from repository info const autoDetectedSettings = this.extractAutoDetectedSettings(repositoryInfo); return { projectPath: resolvedPath, projectName, repositoryInfo, autoDetectedSettings }; } /** * Find Git repository in current directory or parent directories */ async findGitRepository(startPath: string): Promise<RepositoryInfo | null> { let currentPath = path.resolve(startPath); const rootPath = path.parse(currentPath).root; while (currentPath !== rootPath) { const repoInfo = await this.detectRepository(currentPath); if (repoInfo.isGitRepository) { return repoInfo; } // Move to parent directory const parentPath = path.dirname(currentPath); if (parentPath === currentPath) { break; // Reached root } currentPath = parentPath; } return null; } /** * Check if path is within a Git repository */ async isWithinGitRepository(filePath: string): Promise<{ isWithin: boolean; repositoryInfo?: RepositoryInfo; relativePath?: string; }> { const resolvedPath = path.resolve(filePath); const repoInfo = await this.findGitRepository(resolvedPath); if (!repoInfo || !repoInfo.repositoryPath) { return { isWithin: false }; } const relativePath = path.relative(repoInfo.repositoryPath, resolvedPath); const isWithin = !relativePath.startsWith('..') && !path.isAbsolute(relativePath); return { isWithin, repositoryInfo: repoInfo, relativePath: isWithin ? relativePath : undefined }; } /** * Validate project path and ensure it's suitable for Git operations */ async validateProjectPath(projectPath: string): Promise<{ valid: boolean; error?: string; suggestion?: string; repositoryInfo?: RepositoryInfo; }> { try { const resolvedPath = path.resolve(projectPath); // Check if directory exists const dirValidation = await this.terminal.validateDirectory(resolvedPath); if (!dirValidation.exists) { return { valid: false, error: `Directory does not exist: ${resolvedPath}`, suggestion: 'Ensure the project path exists and is accessible' }; } if (!dirValidation.isDirectory) { return { valid: false, error: `Path is not a directory: ${resolvedPath}`, suggestion: 'Provide a directory path, not a file path' }; } // Get repository info const repositoryInfo = await this.detectRepository(resolvedPath); return { valid: true, repositoryInfo }; } catch (error: any) { return { valid: false, error: `Failed to validate project path: ${error.message}`, suggestion: 'Check path permissions and accessibility' }; } } /** * Extract repository name from various sources */ extractRepositoryName(projectPath: string, remoteUrl?: string): string { // Try to extract from remote URL first if (remoteUrl) { const urlName = this.extractRepoNameFromUrl(remoteUrl); if (urlName) { return urlName; } } // Fall back to directory name return path.basename(path.resolve(projectPath)); } /** * Extract owner/username from repository URL or environment */ extractOwnerFromContext(repositoryInfo: RepositoryInfo): string | undefined { if (repositoryInfo.remoteOrigin) { return this.extractOwnerFromUrl(repositoryInfo.remoteOrigin); } // Try to get from Git config return undefined; } /** * Detect provider from remote URL */ detectProviderFromUrl(url?: string): 'github' | 'gitea' | 'unknown' { if (!url) return 'unknown'; const normalizedUrl = url.toLowerCase(); if (normalizedUrl.includes('github.com')) { return 'github'; } // Check for common Gitea patterns if (normalizedUrl.includes('gitea') || normalizedUrl.includes('git.') || normalizedUrl.match(/:\d+\//) || // Custom port (!normalizedUrl.includes('github') && !normalizedUrl.includes('gitlab'))) { return 'gitea'; } return 'unknown'; } /** * Get information about all remotes */ private async getRemotesInfo(repositoryPath: string): Promise<Array<{ name: string; url: string; provider?: 'github' | 'gitea' | 'unknown'; }>> { const remotesResult = await this.gitExecutor.executeGitCommand( 'remote', ['-v'], repositoryPath ); if (!remotesResult.success) { return []; } const remotes: Array<{ name: string; url: string; provider?: 'github' | 'gitea' | 'unknown' }> = []; const lines = remotesResult.stdout.split('\n').filter(line => line.trim()); for (const line of lines) { const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); if (match) { const [, name, url] = match; const provider = this.detectProviderFromUrl(url); remotes.push({ name, url, provider }); } } return remotes; } /** * Get last commit information */ private async getLastCommitInfo(repositoryPath: string): Promise<{ hash: string; message: string; author: string; date: string; } | undefined> { const commitResult = await this.gitExecutor.executeGitCommand( 'log', ['-1', '--format=%H|%s|%an|%ad', '--date=iso'], repositoryPath ); if (commitResult.success && commitResult.stdout.trim()) { const [hash, message, author, date] = commitResult.stdout.trim().split('|'); return { hash, message, author, date }; } return undefined; } /** * Extract auto-detected settings from repository info */ private extractAutoDetectedSettings(repositoryInfo: RepositoryInfo): { owner?: string; repository?: string; provider?: 'github' | 'gitea'; remoteUrl?: string; } { const settings: any = {}; if (repositoryInfo.remoteOrigin) { settings.remoteUrl = repositoryInfo.remoteOrigin; settings.owner = this.extractOwnerFromUrl(repositoryInfo.remoteOrigin); settings.repository = this.extractRepoNameFromUrl(repositoryInfo.remoteOrigin); } if (repositoryInfo.detectedProvider && repositoryInfo.detectedProvider !== 'unknown') { settings.provider = repositoryInfo.detectedProvider; } return settings; } /** * Extract repository name from URL */ private extractRepoNameFromUrl(url: string): string | undefined { try { // Handle SSH URLs like git@github.com:owner/repo.git if (url.startsWith('git@')) { const match = url.match(/git@[^:]+:([^\/]+)\/(.+?)(?:\.git)?$/); if (match) { return match[2]; } } // Handle HTTPS URLs like https://github.com/owner/repo.git if (url.startsWith('http')) { const urlObj = new URL(url); const pathParts = urlObj.pathname.split('/').filter(part => part); if (pathParts.length >= 2) { const repoName = pathParts[pathParts.length - 1]; return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; } } return undefined; } catch { return undefined; } } /** * Extract owner/username from URL */ private extractOwnerFromUrl(url: string): string | undefined { try { // Handle SSH URLs like git@github.com:owner/repo.git if (url.startsWith('git@')) { const match = url.match(/git@[^:]+:([^\/]+)\/(.+?)(?:\.git)?$/); if (match) { return match[1]; } } // Handle HTTPS URLs like https://github.com/owner/repo.git if (url.startsWith('http')) { const urlObj = new URL(url); const pathParts = urlObj.pathname.split('/').filter(part => part); if (pathParts.length >= 2) { return pathParts[pathParts.length - 2]; } } return undefined; } catch { return undefined; } } /** * Check if directory has Git-related files */ async hasGitFiles(directoryPath: string): Promise<{ hasGitDir: boolean; hasGitignore: boolean; hasGitAttributes: boolean; gitFiles: string[]; }> { try { const resolvedPath = path.resolve(directoryPath); const files = await fs.readdir(resolvedPath); const gitFiles: string[] = []; let hasGitDir = false; let hasGitignore = false; let hasGitAttributes = false; for (const file of files) { if (file === '.git') { hasGitDir = true; gitFiles.push(file); } else if (file === '.gitignore') { hasGitignore = true; gitFiles.push(file); } else if (file === '.gitattributes') { hasGitAttributes = true; gitFiles.push(file); } else if (file.startsWith('.git')) { gitFiles.push(file); } } return { hasGitDir, hasGitignore, hasGitAttributes, gitFiles }; } catch { return { hasGitDir: false, hasGitignore: false, hasGitAttributes: false, gitFiles: [] }; } } /** * Suggest Git initialization for non-Git directories */ async suggestGitInit(projectPath: string): Promise<{ shouldInit: boolean; reason: string; suggestion: string; hasExistingFiles: boolean; fileCount: number; }> { try { const resolvedPath = path.resolve(projectPath); const dirValidation = await this.terminal.validateDirectory(resolvedPath); if (!dirValidation.exists) { return { shouldInit: false, reason: 'Directory does not exist', suggestion: 'Create the directory first', hasExistingFiles: false, fileCount: 0 }; } const repoInfo = await this.detectRepository(resolvedPath); if (repoInfo.isGitRepository) { return { shouldInit: false, reason: 'Already a Git repository', suggestion: 'Directory is already initialized as a Git repository', hasExistingFiles: true, fileCount: 0 }; } // Check for existing files const files = await fs.readdir(resolvedPath); const nonHiddenFiles = files.filter(file => !file.startsWith('.')); const hasExistingFiles = nonHiddenFiles.length > 0; return { shouldInit: true, reason: hasExistingFiles ? 'Directory has files but is not a Git repository' : 'Empty directory ready for Git initialization', suggestion: hasExistingFiles ? 'Initialize Git repository and make initial commit' : 'Initialize Git repository', hasExistingFiles, fileCount: nonHiddenFiles.length }; } catch (error: any) { return { shouldInit: false, reason: `Error checking directory: ${error.message}`, suggestion: 'Check directory permissions and try again', hasExistingFiles: false, fileCount: 0 }; } } } // Export singleton instance export const repositoryDetector = new RepositoryDetector();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Andre-Buzeli/git-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server