Files
agent-ui/server/routes/git.ts
josedario87 a856fefd98 feat: Add Git page with branch selector, commit history, and diff viewer
Includes FileTree, CommitList, BranchSelector and DiffViewer components,
Git API routes, and mobile keyboard visibility handling for FAB buttons
2026-02-14 05:49:16 -06:00

347 lines
8.6 KiB
TypeScript

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<string, string> = {
'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 })
}