/** * lib/git.ts — Git helpers for all SmartStack Studio dev CLIs. * Ported from SmartStack.cli/src/mcp/utils/git.ts. * * All functions accept an optional `cwd` parameter (defaults to process.cwd()). * Errors are swallowed and return null/[] for most accessors — callers check * for null results and can add their own error handling if needed. */ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import path from 'node:path'; import { directoryExists } from './fs.js'; const execAsync = promisify(exec); export class GitError extends Error { constructor( message: string, public readonly command: string, public readonly cwd?: string, public readonly cause?: Error, ) { super(message); this.name = 'GitError'; } } export async function git(command: string, cwd?: string): Promise { const options = cwd ? { cwd, maxBuffer: 10 * 1024 * 1024 } : { maxBuffer: 10 * 1024 * 1024 }; try { const { stdout } = await execAsync(`git ${command}`, options); return stdout.trim(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const stderr = (error as { stderr?: string })?.stderr || ''; throw new GitError( `Git command failed: git ${command}${stderr ? ` - ${stderr.trim()}` : ''}`, command, cwd, err, ); } } export async function isGitRepo(cwd?: string): Promise { const gitDir = path.join(cwd || process.cwd(), '.git'); return directoryExists(gitDir); } export async function getCurrentBranch(cwd?: string): Promise { try { return await git('branch --show-current', cwd); } catch { return null; } } export async function isClean(cwd?: string): Promise { try { const status = await git('status --porcelain', cwd); return status === ''; } catch { return false; } } export async function getChangedFiles(cwd?: string): Promise { try { const status = await git('status --porcelain', cwd); if (!status) return []; return status .split('\n') .map((line) => line.substring(3).trim()) .filter(Boolean); } catch { return []; } } export async function getStagedFiles(cwd?: string): Promise { try { const status = await git('diff --cached --name-only', cwd); if (!status) return []; return status.split('\n').filter(Boolean); } catch { return []; } } export async function branchExists(branch: string, cwd?: string): Promise { try { await git(`rev-parse --verify ${branch}`, cwd); return true; } catch { return false; } } export async function getFileFromBranch( branch: string, filePath: string, cwd?: string, ): Promise { try { return await git(`show ${branch}:${filePath}`, cwd); } catch { return null; } } export async function getDiff( fromBranch: string, toBranch: string, filePath?: string, cwd?: string, ): Promise { try { const pathArg = filePath ? ` -- ${filePath}` : ''; return await git(`diff ${fromBranch}...${toBranch}${pathArg}`, cwd); } catch { return ''; } } export async function getRemoteUrl( remote: string = 'origin', cwd?: string, ): Promise { try { return await git(`remote get-url ${remote}`, cwd); } catch { return null; } }