Includes FileTree, CommitList, BranchSelector and DiffViewer components, Git API routes, and mobile keyboard visibility handling for FAB buttons
347 lines
8.6 KiB
TypeScript
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 })
|
|
}
|