Skip to main content
Glama
git-operations.ts14.2 kB
/** * Git Operations Module * * Provides tools for common Git operations within the AL workspace. * Enables version control directly from AI agents. */ import { exec } from 'child_process'; import { promisify } from 'util'; import { getLogger } from '../utils/logger.js'; const execAsync = promisify(exec); export interface GitStatus { branch: string; ahead: number; behind: number; staged: FileChange[]; modified: FileChange[]; untracked: string[]; hasChanges: boolean; } export interface FileChange { path: string; status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied'; oldPath?: string; } export interface GitCommit { hash: string; shortHash: string; author: string; email: string; date: string; message: string; files?: string[]; } export interface GitBranch { name: string; current: boolean; remote?: string; upstream?: string; } /** * Git Operations Manager */ export class GitOperations { private workspaceRoot: string; private logger = getLogger(); constructor(workspaceRoot: string) { this.workspaceRoot = workspaceRoot; } /** * Execute a git command */ private async git(args: string): Promise<{ stdout: string; stderr: string }> { return execAsync(`git ${args}`, { cwd: this.workspaceRoot, maxBuffer: 10 * 1024 * 1024, }); } /** * Check if workspace is a git repository */ async isGitRepository(): Promise<boolean> { try { await this.git('rev-parse --git-dir'); return true; } catch { return false; } } /** * Get current git status */ async getStatus(): Promise<GitStatus> { // Get branch info let branch = ''; let ahead = 0; let behind = 0; try { const { stdout: branchOut } = await this.git('branch --show-current'); branch = branchOut.trim(); // Get ahead/behind counts try { const { stdout: trackingOut } = await this.git(`rev-list --left-right --count ${branch}...@{upstream}`); const [aheadStr, behindStr] = trackingOut.trim().split('\t'); ahead = parseInt(aheadStr) || 0; behind = parseInt(behindStr) || 0; } catch { // No upstream configured } } catch { // Detached HEAD or other issue const { stdout: headOut } = await this.git('rev-parse --short HEAD'); branch = `(detached at ${headOut.trim()})`; } // Get status const staged: FileChange[] = []; const modified: FileChange[] = []; const untracked: string[] = []; try { const { stdout: statusOut } = await this.git('status --porcelain=v2'); const lines = statusOut.trim().split('\n').filter(l => l); for (const line of lines) { if (line.startsWith('1 ')) { // Ordinary changed entry const parts = line.split(' '); const xy = parts[1]; const filePath = parts.slice(8).join(' '); if (xy[0] !== '.') { // Staged change staged.push({ path: filePath, status: this.parseStatusCode(xy[0]), }); } if (xy[1] !== '.') { // Unstaged change modified.push({ path: filePath, status: this.parseStatusCode(xy[1]), }); } } else if (line.startsWith('2 ')) { // Renamed/copied entry const parts = line.split(' '); const xy = parts[1]; const pathParts = parts.slice(9).join(' ').split('\t'); const newPath = pathParts[0]; const oldPath = pathParts[1]; if (xy[0] !== '.') { staged.push({ path: newPath, oldPath, status: xy[0] === 'R' ? 'renamed' : 'copied', }); } } else if (line.startsWith('? ')) { // Untracked file untracked.push(line.slice(2)); } } } catch (error) { this.logger.error('Failed to get git status:', error); } return { branch, ahead, behind, staged, modified, untracked, hasChanges: staged.length > 0 || modified.length > 0 || untracked.length > 0, }; } /** * Parse status code to status string */ private parseStatusCode(code: string): FileChange['status'] { switch (code) { case 'A': return 'added'; case 'M': return 'modified'; case 'D': return 'deleted'; case 'R': return 'renamed'; case 'C': return 'copied'; default: return 'modified'; } } /** * Get diff of changes */ async getDiff(options?: { staged?: boolean; file?: string; unified?: number; }): Promise<string> { let args = 'diff'; if (options?.staged) { args += ' --staged'; } if (options?.unified !== undefined) { args += ` -U${options.unified}`; } if (options?.file) { args += ` -- "${options.file}"`; } const { stdout } = await this.git(args); return stdout; } /** * Stage files */ async stage(paths: string[] | 'all'): Promise<{ success: boolean; message: string }> { try { if (paths === 'all') { await this.git('add -A'); } else { const pathsStr = paths.map(p => `"${p}"`).join(' '); await this.git(`add ${pathsStr}`); } return { success: true, message: 'Files staged successfully' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Unstage files */ async unstage(paths: string[] | 'all'): Promise<{ success: boolean; message: string }> { try { if (paths === 'all') { await this.git('reset HEAD'); } else { const pathsStr = paths.map(p => `"${p}"`).join(' '); await this.git(`reset HEAD -- ${pathsStr}`); } return { success: true, message: 'Files unstaged successfully' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Commit staged changes */ async commit(message: string, options?: { amend?: boolean; allowEmpty?: boolean; }): Promise<{ success: boolean; message: string; hash?: string }> { try { let args = `commit -m "${message.replace(/"/g, '\\"')}"`; if (options?.amend) { args += ' --amend'; } if (options?.allowEmpty) { args += ' --allow-empty'; } const { stdout } = await this.git(args); // Extract commit hash const hashMatch = stdout.match(/\[[\w-]+ ([a-f0-9]+)\]/); const hash = hashMatch ? hashMatch[1] : undefined; return { success: true, message: 'Committed successfully', hash }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Get commit history */ async getLog(options?: { limit?: number; since?: string; until?: string; author?: string; grep?: string; file?: string; }): Promise<GitCommit[]> { const format = '--format=%H|%h|%an|%ae|%aI|%s'; let args = `log ${format}`; if (options?.limit) { args += ` -n ${options.limit}`; } if (options?.since) { args += ` --since="${options.since}"`; } if (options?.until) { args += ` --until="${options.until}"`; } if (options?.author) { args += ` --author="${options.author}"`; } if (options?.grep) { args += ` --grep="${options.grep}"`; } if (options?.file) { args += ` -- "${options.file}"`; } try { const { stdout } = await this.git(args); const lines = stdout.trim().split('\n').filter(l => l); return lines.map(line => { const [hash, shortHash, author, email, date, message] = line.split('|'); return { hash, shortHash, author, email, date, message }; }); } catch { return []; } } /** * List branches */ async listBranches(remote?: boolean): Promise<GitBranch[]> { const branches: GitBranch[] = []; try { const { stdout } = await this.git(`branch ${remote ? '-r' : ''} -vv`); const lines = stdout.trim().split('\n').filter(l => l); for (const line of lines) { const current = line.startsWith('*'); const parts = line.slice(2).trim().split(/\s+/); const name = parts[0]; // Extract upstream from [origin/main] format const upstreamMatch = line.match(/\[([^\]]+)\]/); const upstream = upstreamMatch ? upstreamMatch[1].split(':')[0] : undefined; branches.push({ name, current, remote: remote ? name.split('/')[0] : undefined, upstream, }); } } catch (error) { this.logger.error('Failed to list branches:', error); } return branches; } /** * Create a new branch */ async createBranch(name: string, options?: { checkout?: boolean; from?: string; }): Promise<{ success: boolean; message: string }> { try { let args = `branch "${name}"`; if (options?.from) { args += ` "${options.from}"`; } await this.git(args); if (options?.checkout) { await this.git(`checkout "${name}"`); } return { success: true, message: `Branch '${name}' created${options?.checkout ? ' and checked out' : ''}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Switch to a branch */ async checkout(branchOrPath: string, options?: { create?: boolean; }): Promise<{ success: boolean; message: string }> { try { let args = 'checkout'; if (options?.create) { args += ' -b'; } args += ` "${branchOrPath}"`; await this.git(args); return { success: true, message: `Switched to '${branchOrPath}'` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Pull changes from remote */ async pull(options?: { remote?: string; branch?: string; rebase?: boolean; }): Promise<{ success: boolean; message: string }> { try { let args = 'pull'; if (options?.rebase) { args += ' --rebase'; } if (options?.remote) { args += ` ${options.remote}`; if (options?.branch) { args += ` ${options.branch}`; } } const { stdout, stderr } = await this.git(args); return { success: true, message: (stdout + stderr).trim() || 'Pull successful' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Push changes to remote */ async push(options?: { remote?: string; branch?: string; setUpstream?: boolean; force?: boolean; }): Promise<{ success: boolean; message: string }> { try { let args = 'push'; if (options?.setUpstream) { args += ' -u'; } if (options?.force) { args += ' --force-with-lease'; } if (options?.remote) { args += ` ${options.remote}`; if (options?.branch) { args += ` ${options.branch}`; } } const { stdout, stderr } = await this.git(args); return { success: true, message: (stdout + stderr).trim() || 'Push successful' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Stash changes */ async stash(options?: { message?: string; includeUntracked?: boolean; }): Promise<{ success: boolean; message: string }> { try { let args = 'stash push'; if (options?.includeUntracked) { args += ' -u'; } if (options?.message) { args += ` -m "${options.message}"`; } await this.git(args); return { success: true, message: 'Changes stashed' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * Apply stash */ async stashPop(index?: number): Promise<{ success: boolean; message: string }> { try { const stashRef = index !== undefined ? `stash@{${index}}` : ''; await this.git(`stash pop ${stashRef}`); return { success: true, message: 'Stash applied and removed' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } /** * List stashes */ async stashList(): Promise<{ index: number; message: string; date: string }[]> { try { const { stdout } = await this.git('stash list --format=%gd|%s|%ai'); const lines = stdout.trim().split('\n').filter(l => l); return lines.map(line => { const [ref, message, date] = line.split('|'); const index = parseInt(ref.match(/\{(\d+)\}/)?.[1] || '0'); return { index, message, date }; }); } catch { return []; } } /** * Discard changes in a file */ async discardChanges(path: string): Promise<{ success: boolean; message: string }> { try { if (path === 'all') { await this.git('checkout -- .'); } else { await this.git(`checkout -- "${path}"`); } return { success: true, message: 'Changes discarded' }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, message: errorMessage }; } } }

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/ciellosinc/partnercore-proxy'

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