import { jsonResponse, errorResponse } from '../utils/cors' // Execute git command and return stdout async function execGit(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(['git', ...args], { cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' }) const stdout = await new Response(proc.stdout).text() const stderr = await new Response(proc.stderr).text() const exitCode = await proc.exited return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode } } // Parse git status porcelain output function parseStatus(output: string): { staged: any[]; unstaged: any[]; untracked: string[] } { const staged: any[] = [] const unstaged: any[] = [] const untracked: string[] = [] if (!output) return { staged, unstaged, untracked } const lines = output.split('\n').filter(Boolean) for (const line of lines) { const indexStatus = line[0] const workTreeStatus = line[1] const path = line.slice(3) // Untracked if (indexStatus === '?' && workTreeStatus === '?') { untracked.push(path) continue } // Staged changes if (indexStatus !== ' ' && indexStatus !== '?') { staged.push({ path, status: parseStatusCode(indexStatus), staged: true }) } // Unstaged changes if (workTreeStatus !== ' ' && workTreeStatus !== '?') { unstaged.push({ path, status: parseStatusCode(workTreeStatus), staged: false }) } } return { staged, unstaged, untracked } } function parseStatusCode(code: string): string { const map: Record = { 'M': 'modified', 'A': 'added', 'D': 'deleted', 'R': 'renamed', 'C': 'copied', 'U': 'unmerged', '?': 'untracked' } return map[code] || 'unknown' } // GET /api/git/status export async function handleGitStatus() { const { stdout: branch } = await execGit(['branch', '--show-current']) const { stdout: statusOutput, exitCode } = await execGit(['status', '--porcelain', '-uall']) if (exitCode !== 0) { return errorResponse('Not a git repository', 400) } const { staged, unstaged, untracked } = parseStatus(statusOutput) // Get ahead/behind info let ahead = 0 let behind = 0 const { stdout: trackingInfo } = await execGit(['rev-list', '--left-right', '--count', '@{upstream}...HEAD']) if (trackingInfo) { const parts = trackingInfo.split('\t') if (parts.length === 2) { behind = parseInt(parts[0]) || 0 ahead = parseInt(parts[1]) || 0 } } return jsonResponse({ branch, staged, unstaged, untracked, ahead, behind }) } // GET /api/git/diff export async function handleGitDiff(url: URL) { const staged = url.searchParams.get('staged') === 'true' const file = url.searchParams.get('file') const args = ['diff'] if (staged) args.push('--cached') if (file) args.push('--', file) const { stdout, exitCode } = await execGit(args) if (exitCode !== 0) { return errorResponse('Failed to get diff', 400) } // Parse diff into structured format const files = parseDiff(stdout) return jsonResponse({ raw: stdout, files }) } // Parse unified diff format function parseDiff(diffOutput: string): any[] { if (!diffOutput) return [] const files: any[] = [] const fileDiffs = diffOutput.split(/(?=^diff --git)/m).filter(Boolean) for (const fileDiff of fileDiffs) { const lines = fileDiff.split('\n') const headerMatch = lines[0]?.match(/^diff --git a\/(.+) b\/(.+)$/) if (!headerMatch) continue const oldPath = headerMatch[1] const newPath = headerMatch[2] // Determine status let status = 'modified' if (lines.some(l => l.startsWith('new file'))) status = 'added' if (lines.some(l => l.startsWith('deleted file'))) status = 'deleted' if (lines.some(l => l.startsWith('rename '))) status = 'renamed' // Check if binary const isBinary = lines.some(l => l.includes('Binary files')) // Parse hunks const hunks: any[] = [] let currentHunk: any = null for (const line of lines) { const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/) if (hunkMatch) { if (currentHunk) hunks.push(currentHunk) currentHunk = { oldStart: parseInt(hunkMatch[1]), oldLines: parseInt(hunkMatch[2]) || 1, newStart: parseInt(hunkMatch[3]), newLines: parseInt(hunkMatch[4]) || 1, header: hunkMatch[5].trim(), lines: [] } continue } if (currentHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) { currentHunk.lines.push({ type: line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'context', content: line.slice(1) }) } } if (currentHunk) hunks.push(currentHunk) files.push({ path: newPath, oldPath: oldPath !== newPath ? oldPath : undefined, status, isBinary, hunks }) } return files } // GET /api/git/log export async function handleGitLog(url: URL) { const limit = parseInt(url.searchParams.get('limit') || '50') const offset = parseInt(url.searchParams.get('offset') || '0') const author = url.searchParams.get('author') const since = url.searchParams.get('since') const args = [ 'log', `--skip=${offset}`, `-n${limit}`, '--format=%H|%h|%an|%ae|%at|%s' ] if (author) args.push(`--author=${author}`) if (since) args.push(`--since=${since}`) const { stdout, exitCode } = await execGit(args) if (exitCode !== 0) { return errorResponse('Failed to get log', 400) } if (!stdout) { return jsonResponse([]) } const commits = stdout.split('\n').filter(Boolean).map(line => { const [sha, shortSha, author, email, timestamp, message] = line.split('|') return { sha, shortSha, author, email, timestamp: parseInt(timestamp), message } }) return jsonResponse(commits) } // GET /api/git/log/:sha export async function handleGitLogCommit(sha: string) { const { stdout: details, exitCode } = await execGit([ 'show', sha, '--format=%H|%h|%an|%ae|%at|%s%n%b', '--stat' ]) if (exitCode !== 0) { return errorResponse('Commit not found', 404) } const lines = details.split('\n') const [sha_, shortSha, author, email, timestamp, message] = lines[0].split('|') // Get body (everything between first line and stats) let body = '' let statsStart = lines.findIndex(l => l.match(/^\s+\d+ files? changed/)) if (statsStart === -1) statsStart = lines.length body = lines.slice(1, statsStart).join('\n').trim() // Get diff for this commit const { stdout: diffOutput } = await execGit(['show', sha, '--format=']) const files = parseDiff(diffOutput) return jsonResponse({ sha: sha_, shortSha, author, email, timestamp: parseInt(timestamp), message, body, files }) } // POST /api/git/compare export async function handleGitCompare(req: Request) { const body = await req.json() const { base, head } = body if (!base || !head) { return errorResponse('base and head are required', 400) } const { stdout, exitCode, stderr } = await execGit(['diff', `${base}...${head}`]) if (exitCode !== 0) { return errorResponse(stderr || 'Failed to compare', 400) } const files = parseDiff(stdout) // Calculate stats let additions = 0 let deletions = 0 for (const file of files) { for (const hunk of file.hunks || []) { for (const line of hunk.lines || []) { if (line.type === 'add') additions++ if (line.type === 'delete') deletions++ } } } return jsonResponse({ base, head, files, stats: { filesChanged: files.length, additions, deletions } }) } // GET /api/git/branches export async function handleGitBranches() { const { stdout, exitCode } = await execGit(['branch', '-a', '--format=%(refname:short)|%(HEAD)']) if (exitCode !== 0) { return errorResponse('Failed to get branches', 400) } if (!stdout) { return jsonResponse([]) } const branches = stdout.split('\n').filter(Boolean).map(line => { const [name, isCurrent] = line.split('|') return { name: name.trim(), isCurrent: isCurrent === '*', isRemote: name.startsWith('origin/') } }) return jsonResponse(branches) } // GET /api/git/branch/current export async function handleGitCurrentBranch() { const { stdout, exitCode } = await execGit(['branch', '--show-current']) if (exitCode !== 0) { return errorResponse('Not a git repository', 400) } return jsonResponse({ branch: stdout }) }