Files
orca/src/relay/git-handler-utils.ts
T

325 lines
8.7 KiB
TypeScript

/**
* Pure parsing helpers extracted from git-handler.ts.
*
* Why: oxlint max-lines requires files to stay under 300 lines.
* These functions have no side-effects and depend only on their arguments,
* making them easy to test independently.
*/
import * as path from 'path'
import { existsSync } from 'fs'
// ─── Status parsing ──────────────────────────────────────────────────
export function parseStatusChar(char: string): string {
switch (char) {
case 'M':
return 'modified'
case 'A':
return 'added'
case 'D':
return 'deleted'
case 'R':
return 'renamed'
case 'C':
return 'copied'
default:
return 'modified'
}
}
export function parseBranchStatusChar(char: string): string {
switch (char) {
case 'M':
return 'modified'
case 'A':
return 'added'
case 'D':
return 'deleted'
case 'R':
return 'renamed'
case 'C':
return 'copied'
default:
return 'modified'
}
}
export function parseConflictKind(xy: string): string | null {
switch (xy) {
case 'UU':
return 'both_modified'
case 'AA':
return 'both_added'
case 'DD':
return 'both_deleted'
case 'AU':
return 'added_by_us'
case 'UA':
return 'added_by_them'
case 'DU':
return 'deleted_by_us'
case 'UD':
return 'deleted_by_them'
default:
return null
}
}
/**
* Parse `git status --porcelain=v2` output into structured entries.
* Does NOT handle unmerged entries (those require worktree access).
*/
export function parseStatusOutput(stdout: string): {
entries: Record<string, unknown>[]
unmergedLines: string[]
} {
const entries: Record<string, unknown>[] = []
const unmergedLines: string[] = []
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
if (line.startsWith('1 ') || line.startsWith('2 ')) {
const parts = line.split(' ')
const xy = parts[1]
const indexStatus = xy[0]
const worktreeStatus = xy[1]
if (line.startsWith('2 ')) {
// Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`.
// The new path is the last space-delimited token before the tab; origPath follows the tab.
const tabParts = line.split('\t')
const spaceParts = tabParts[0].split(' ')
const filePath = spaceParts.at(-1)!
const oldPath = tabParts[1]
if (indexStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(indexStatus),
area: 'staged',
oldPath
})
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged',
oldPath
})
}
} else {
const filePath = parts.slice(8).join(' ')
if (indexStatus !== '.') {
entries.push({ path: filePath, status: parseStatusChar(indexStatus), area: 'staged' })
}
if (worktreeStatus !== '.') {
entries.push({
path: filePath,
status: parseStatusChar(worktreeStatus),
area: 'unstaged'
})
}
}
} else if (line.startsWith('? ')) {
entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' })
} else if (line.startsWith('u ')) {
unmergedLines.push(line)
}
}
return { entries, unmergedLines }
}
/**
* Parse a single unmerged entry line from porcelain v2 output.
* Returns null if the entry should be skipped (e.g. submodule conflicts).
*/
export function parseUnmergedEntry(
worktreePath: string,
line: string
): Record<string, unknown> | null {
const parts = line.split(' ')
const xy = parts[1]
const modeStage1 = parts[3]
const modeStage2 = parts[4]
const modeStage3 = parts[5]
const filePath = parts.slice(10).join(' ')
if (!filePath) {
return null
}
// Skip submodule conflicts (mode 160000)
if ([modeStage1, modeStage2, modeStage3].some((m) => m === '160000')) {
return null
}
const conflictKind = parseConflictKind(xy)
if (!conflictKind) {
return null
}
let status: string = 'modified'
if (conflictKind === 'both_deleted') {
status = 'deleted'
} else if (conflictKind !== 'both_modified' && conflictKind !== 'both_added') {
try {
status = existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted'
} catch {
// Why: defaulting to 'modified' on fs error is the least misleading option
status = 'modified'
}
}
return {
path: filePath,
area: 'unstaged',
status,
conflictKind,
conflictStatus: 'unresolved'
}
}
// ─── Branch diff parsing ─────────────────────────────────────────────
/**
* Parse `git diff --name-status` output into structured change entries.
*/
export function parseBranchDiff(stdout: string): Record<string, unknown>[] {
const entries: Record<string, unknown>[] = []
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const parts = line.split('\t')
const rawStatus = parts[0] ?? ''
const status = parseBranchStatusChar(rawStatus[0] ?? 'M')
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
const oldPath = parts[1]
const filePath = parts[2]
if (filePath) {
entries.push({ path: filePath, oldPath, status })
}
} else {
const filePath = parts[1]
if (filePath) {
entries.push({ path: filePath, status })
}
}
}
return entries
}
// ─── Worktree parsing ────────────────────────────────────────────────
export function parseWorktreeList(output: string): Record<string, unknown>[] {
const worktrees: Record<string, unknown>[] = []
const blocks = output.trim().split(/\r?\n\r?\n/)
for (const block of blocks) {
if (!block.trim()) {
continue
}
const lines = block.trim().split(/\r?\n/)
let wtPath = ''
let head = ''
let branch = ''
let isBare = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
wtPath = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
}
}
if (wtPath) {
worktrees.push({
path: wtPath,
head,
branch,
isBare,
isMainWorktree: worktrees.length === 0
})
}
}
return worktrees
}
// ─── Binary / blob helpers ───────────────────────────────────────────
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i++) {
if (buffer[i] === 0) {
return true
}
}
return false
}
export const PREVIEWABLE_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf'
}
export function bufferToBlob(
buffer: Buffer,
filePath?: string
): { content: string; isBinary: boolean } {
const binary = isBinaryBuffer(buffer)
if (binary) {
const ext = filePath ? path.extname(filePath).toLowerCase() : ''
const previewable = !!PREVIEWABLE_MIME[ext]
return { content: previewable ? buffer.toString('base64') : '', isBinary: true }
}
return { content: buffer.toString('utf-8'), isBinary: false }
}
/**
* Build a diff result object from original/modified content.
* Used by both working-tree diffs and branch diffs.
*/
export function buildDiffResult(
originalContent: string,
modifiedContent: string,
originalIsBinary: boolean,
modifiedIsBinary: boolean,
filePath?: string
) {
if (originalIsBinary || modifiedIsBinary) {
const ext = filePath ? path.extname(filePath).toLowerCase() : ''
const mimeType = PREVIEWABLE_MIME[ext]
return {
kind: 'binary' as const,
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary,
...(mimeType ? { isImage: true, mimeType } : {})
}
}
return {
kind: 'text' as const,
originalContent,
modifiedContent,
originalIsBinary: false,
modifiedIsBinary: false
}
}