Skip to main content
Glama
git.ts11.2 kB
/** * Git Utilities * * DESIGN PATTERNS: * - Safe command execution: Use execa with array arguments to prevent shell injection * - Defense in depth: Use '--' separator to prevent option injection attacks * * CODING STANDARDS: * - All git commands must use execGit helper with array arguments * - Use '--' separator before user-provided arguments (URLs, branches, paths) * - Validate inputs where appropriate * * AVOID: * - Shell string interpolation * - Passing unsanitized user input as command options * * NOTE: These utilities perform I/O operations (git commands, file system) by necessity. * Pure utility functions like parseGitHubUrl are side-effect free. */ import path from 'node:path'; import { execa } from 'execa'; import { pathExists, writeFile, move, remove } from './fsHelpers'; /** * Type guard to check if an error has the expected execa error shape * @param error - The error to check * @returns True if the error has message property (and optionally stderr) * @example * try { * await execa('git', ['status']); * } catch (error) { * if (isExecaError(error)) { * console.error(error.stderr || error.message); * } * } */ function isExecaError(error: unknown): error is { stderr?: string; message: string } { if (typeof error !== 'object' || error === null) { return false; } if (!('message' in error)) { return false; } const errorObj = error as Record<string, unknown>; return typeof errorObj.message === 'string'; } /** * Parsed GitHub URL result */ export interface ParsedGitHubUrl { owner?: string; repo?: string; repoUrl: string; branch?: string; subdirectory?: string; isSubdirectory: boolean; } /** * GitHub directory entry */ export interface GitHubDirectoryEntry { name: string; type: string; path: string; } /** * Execute a git command safely using execa to prevent command injection * @param args - Array of git command arguments * @param cwd - Optional working directory for the command * @throws Error when git command fails */ async function execGit(args: string[], cwd?: string): Promise<void> { try { await execa('git', args, { cwd }); } catch (error) { if (isExecaError(error)) { throw new Error(`Git command failed: ${error.stderr || error.message}`); } throw error; } } /** * Execute git init safely using execa to prevent command injection * Uses '--' to prevent projectPath from being interpreted as an option * @param projectPath - Path where to initialize the git repository * @throws Error when git init fails */ export async function gitInit(projectPath: string): Promise<void> { try { await execa('git', ['init', '--', projectPath]); } catch (error) { if (isExecaError(error)) { throw new Error(`Git init failed: ${error.stderr || error.message}`); } throw error; } } /** * Find the workspace root by searching upwards for .git folder * Returns null if no .git folder is found (indicating a new project setup is needed) * @param startPath - The path to start searching from (default: current working directory) * @returns The workspace root path or null if not in a git repository * @example * const root = await findWorkspaceRoot('/path/to/project/src'); * if (root) { * console.log('Workspace root:', root); * } else { * console.log('No git repository found'); * } */ export async function findWorkspaceRoot(startPath: string = process.cwd()): Promise<string | null> { let currentPath = path.resolve(startPath); const rootPath = path.parse(currentPath).root; while (true) { // Check if .git folder exists (repository root) const gitPath = path.join(currentPath, '.git'); if (await pathExists(gitPath)) { return currentPath; } // Check if we've reached the filesystem root if (currentPath === rootPath) { // No .git found, return null to indicate new project setup needed return null; } // Move up to parent directory currentPath = path.dirname(currentPath); } } /** * Parse GitHub URL to detect if it's a subdirectory * Supports formats: * - https://github.com/user/repo * - https://github.com/user/repo/tree/branch/path/to/dir * - https://github.com/user/repo/tree/main/path/to/dir * @param url - The GitHub URL to parse * @returns Parsed URL components including owner, repo, branch, and subdirectory * @example * const result = parseGitHubUrl('https://github.com/user/repo/tree/main/src'); * // result.owner === 'user' * // result.repo === 'repo' * // result.branch === 'main' * // result.subdirectory === 'src' * // result.isSubdirectory === true */ export function parseGitHubUrl(url: string): ParsedGitHubUrl { // Match: https://github.com/(owner)/(repo)/tree/(branch)/(path...) const treeMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/); // Match: https://github.com/(owner)/(repo)/blob/(branch)/(path...) const blobMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/); // Match: https://github.com/(owner)/(repo) with optional .git suffix const rootMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); if (treeMatch || blobMatch) { const match = treeMatch || blobMatch; return { owner: match?.[1], repo: match?.[2], repoUrl: `https://github.com/${match?.[1]}/${match?.[2]}.git`, branch: match?.[3], subdirectory: match?.[4], isSubdirectory: true, }; } if (rootMatch) { return { owner: rootMatch[1], repo: rootMatch[2], repoUrl: `https://github.com/${rootMatch[1]}/${rootMatch[2]}.git`, isSubdirectory: false, }; } // If it doesn't match GitHub patterns, assume it's a direct git URL return { repoUrl: url, isSubdirectory: false, }; } /** * Clone a subdirectory from a git repository using sparse checkout * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil' * from being interpreted as git options * @param repoUrl - The git repository URL * @param branch - The branch to clone from * @param subdirectory - The subdirectory path within the repository * @param targetFolder - The local folder to clone into * @throws Error if subdirectory not found or target folder already exists * @example * await cloneSubdirectory( * 'https://github.com/user/repo.git', * 'main', * 'packages/core', * './my-project' * ); */ export async function cloneSubdirectory( repoUrl: string, branch: string, subdirectory: string, targetFolder: string, ): Promise<void> { const tempFolder = `${targetFolder}.tmp`; try { // Initialize a new git repo (use '--' to prevent tempFolder injection) await execGit(['init', '--', tempFolder]); // Add remote (use '--' to prevent URL injection) await execGit(['remote', 'add', 'origin', '--', repoUrl], tempFolder); // Enable sparse checkout await execGit(['config', 'core.sparseCheckout', 'true'], tempFolder); // Configure sparse checkout to only include the subdirectory const sparseCheckoutFile = path.join(tempFolder, '.git', 'info', 'sparse-checkout'); await writeFile(sparseCheckoutFile, `${subdirectory}\n`); // Pull the specific branch (use '--' to prevent branch name injection) await execGit(['pull', '--depth=1', 'origin', '--', branch], tempFolder); // Move the subdirectory content to target folder const sourceDir = path.join(tempFolder, subdirectory); if (!(await pathExists(sourceDir))) { throw new Error( `Subdirectory '${subdirectory}' not found in repository at branch '${branch}'`, ); } // Check if target folder already exists if (await pathExists(targetFolder)) { throw new Error(`Target folder already exists: ${targetFolder}`); } await move(sourceDir, targetFolder); // Clean up temp folder await remove(tempFolder); } catch (error) { // Clean up temp folder on error if (await pathExists(tempFolder)) { await remove(tempFolder); } throw error; } } /** * Clone entire repository * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil' * from being interpreted as git options * @param repoUrl - The git repository URL to clone * @param targetFolder - The local folder path to clone into * @throws Error if git clone fails * @example * await cloneRepository('https://github.com/user/repo.git', './my-project'); */ export async function cloneRepository(repoUrl: string, targetFolder: string): Promise<void> { await execGit(['clone', '--', repoUrl, targetFolder]); // Remove .git folder const gitFolder = path.join(targetFolder, '.git'); if (await pathExists(gitFolder)) { await remove(gitFolder); } } /** * GitHub API content item interface */ interface GitHubContentItem { name: string; type: 'file' | 'dir' | 'symlink' | 'submodule'; path: string; sha: string; size: number; url: string; html_url: string; git_url: string; download_url: string | null; } /** * Type guard to validate GitHub API content item structure * @param item - The item to validate * @returns True if the item has required GitHubContentItem properties */ function isGitHubContentItem(item: unknown): item is GitHubContentItem { if (typeof item !== 'object' || item === null) { return false; } const obj = item as Record<string, unknown>; return ( typeof obj.name === 'string' && typeof obj.type === 'string' && typeof obj.path === 'string' ); } /** * Fetch directory listing from GitHub API * @param owner - The GitHub repository owner/organization * @param repo - The repository name * @param dirPath - The directory path within the repository * @param branch - The branch to fetch from (default: 'main') * @returns Array of directory entries with name, type, and path * @throws Error if the API request fails or returns non-directory content * @remarks * - Requires network access to GitHub API * - Subject to GitHub API rate limits (60 requests/hour unauthenticated) * - Only works with public repositories without authentication * @example * const contents = await fetchGitHubDirectoryContents('facebook', 'react', 'packages', 'main'); * // contents: [{ name: 'react', type: 'dir', path: 'packages/react' }, ...] */ export async function fetchGitHubDirectoryContents( owner: string, repo: string, dirPath: string, branch = 'main', ): Promise<GitHubDirectoryEntry[]> { const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${branch}`; const response = await fetch(url, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'scaffold-mcp', }, }); if (!response.ok) { throw new Error(`Failed to fetch directory contents: ${response.statusText}`); } const data: unknown = await response.json(); if (!Array.isArray(data)) { throw new Error('Expected directory but got file'); } return data.filter(isGitHubContentItem).map( (item: GitHubContentItem): GitHubDirectoryEntry => ({ name: item.name, type: item.type, path: item.path, }), ); }

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/AgiFlow/aicode-toolkit'

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