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
This commit is contained in:
346
server/routes/git.ts
Normal file
346
server/routes/git.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
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 })
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
|
||||
import { handleWhisperRoutes } from './whisper'
|
||||
import { handleRecordingsRoutes } from './recordings'
|
||||
import { handleClaudeStatus } from './claude-status'
|
||||
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch } from './git'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -189,5 +190,35 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Git
|
||||
if (path === '/api/git/status' && req.method === 'GET') {
|
||||
return handleGitStatus()
|
||||
}
|
||||
|
||||
if (path === '/api/git/diff' && req.method === 'GET') {
|
||||
return handleGitDiff(url)
|
||||
}
|
||||
|
||||
if (path === '/api/git/log' && req.method === 'GET') {
|
||||
return handleGitLog(url)
|
||||
}
|
||||
|
||||
const gitLogCommitMatch = path.match(/^\/api\/git\/log\/([a-f0-9]+)$/)
|
||||
if (gitLogCommitMatch && req.method === 'GET') {
|
||||
return handleGitLogCommit(gitLogCommitMatch[1])
|
||||
}
|
||||
|
||||
if (path === '/api/git/compare' && req.method === 'POST') {
|
||||
return handleGitCompare(req)
|
||||
}
|
||||
|
||||
if (path === '/api/git/branches' && req.method === 'GET') {
|
||||
return handleGitBranches()
|
||||
}
|
||||
|
||||
if (path === '/api/git/branch/current' && req.method === 'GET') {
|
||||
return handleGitCurrentBranch()
|
||||
}
|
||||
|
||||
return notFoundResponse()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user