/**
* Git Diff Parser
*
* Parses git diff output into structured data for rendering
*/
/**
* Parse raw git diff output into structured data
* @param {string} rawDiff - Raw git diff output
* @returns {Array<DiffFile>} Array of diff files
*
* DiffFile: {
* oldPath: string,
* newPath: string,
* status: 'added' | 'deleted' | 'modified' | 'renamed',
* hunks: Array<Hunk>,
* additions: number,
* deletions: number,
* isBinary: boolean
* }
*
* Hunk: {
* header: string,
* oldStart: number,
* oldCount: number,
* newStart: number,
* newCount: number,
* lines: Array<DiffLine>
* }
*
* DiffLine: {
* type: 'add' | 'del' | 'ctx' | 'hunk-header',
* content: string,
* oldLineNo: number | null,
* newLineNo: number | null
* }
*/
export function parseDiff(rawDiff) {
if (!rawDiff || typeof rawDiff !== 'string') {
return []
}
const files = []
const lines = rawDiff.split('\n')
let currentFile = null
let currentHunk = null
let oldLineNo = 0
let newLineNo = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// New file diff header
if (line.startsWith('diff --git')) {
// Save previous file
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk)
}
files.push(currentFile)
}
// Extract paths from diff header
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/)
currentFile = {
oldPath: match ? match[1] : '',
newPath: match ? match[2] : '',
status: 'modified',
hunks: [],
additions: 0,
deletions: 0,
isBinary: false
}
currentHunk = null
continue
}
if (!currentFile) continue
// Binary file marker
if (line.startsWith('Binary files')) {
currentFile.isBinary = true
continue
}
// New file marker
if (line.startsWith('new file mode')) {
currentFile.status = 'added'
continue
}
// Deleted file marker
if (line.startsWith('deleted file mode')) {
currentFile.status = 'deleted'
continue
}
// Renamed file markers
if (line.startsWith('rename from ')) {
currentFile.status = 'renamed'
currentFile.oldPath = line.slice(12)
continue
}
if (line.startsWith('rename to ')) {
currentFile.newPath = line.slice(10)
continue
}
// Similarity index (for renames)
if (line.startsWith('similarity index')) {
continue
}
// Old/new file path headers (skip, we got paths from diff --git)
if (line.startsWith('---') || line.startsWith('+++')) {
continue
}
// Index line (skip)
if (line.startsWith('index ')) {
continue
}
// Hunk header
if (line.startsWith('@@')) {
// Save previous hunk
if (currentHunk) {
currentFile.hunks.push(currentHunk)
}
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@
const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/)
if (hunkMatch) {
oldLineNo = parseInt(hunkMatch[1], 10)
newLineNo = parseInt(hunkMatch[3], 10)
currentHunk = {
header: line,
oldStart: oldLineNo,
oldCount: parseInt(hunkMatch[2] || '1', 10),
newStart: newLineNo,
newCount: parseInt(hunkMatch[4] || '1', 10),
context: hunkMatch[5]?.trim() || '',
lines: []
}
}
continue
}
// Diff content lines
if (currentHunk) {
const firstChar = line[0]
const content = line.slice(1)
if (firstChar === '+') {
currentHunk.lines.push({
type: 'add',
content,
oldLineNo: null,
newLineNo: newLineNo++
})
currentFile.additions++
} else if (firstChar === '-') {
currentHunk.lines.push({
type: 'del',
content,
oldLineNo: oldLineNo++,
newLineNo: null
})
currentFile.deletions++
} else if (firstChar === ' ' || firstChar === undefined) {
currentHunk.lines.push({
type: 'ctx',
content: content || '',
oldLineNo: oldLineNo++,
newLineNo: newLineNo++
})
} else if (firstChar === '\\') {
// "\ No newline at end of file" - add as special marker
currentHunk.lines.push({
type: 'info',
content: line,
oldLineNo: null,
newLineNo: null
})
}
}
}
// Save last file
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk)
}
files.push(currentFile)
}
return files
}
/**
* Parse commit info from git log output
* Format: %H%n%P%n%an%n%ae%n%at%n%s%n%b
* @param {string} raw - Raw git log output
* @returns {object} Commit info
*/
export function parseCommitInfo(raw) {
if (!raw) return null
const lines = raw.split('\n')
return {
hash: lines[0] || '',
shortHash: (lines[0] || '').slice(0, 7),
parents: (lines[1] || '').split(' ').filter(Boolean),
author: lines[2] || '',
email: lines[3] || '',
timestamp: parseInt(lines[4], 10) * 1000 || Date.now(),
date: new Date(parseInt(lines[4], 10) * 1000).toLocaleString(),
subject: lines[5] || '',
body: lines.slice(6).join('\n').trim()
}
}
/**
* Calculate diff stats summary
* @param {Array<DiffFile>} files - Parsed diff files
* @returns {object} Stats summary
*/
export function getDiffStats(files) {
let totalAdditions = 0
let totalDeletions = 0
let filesChanged = files.length
for (const file of files) {
totalAdditions += file.additions
totalDeletions += file.deletions
}
return {
filesChanged,
additions: totalAdditions,
deletions: totalDeletions,
total: totalAdditions + totalDeletions
}
}
/**
* Get file status label
* @param {string} status - File status
* @returns {object} { label, color }
*/
export function getStatusLabel(status) {
const labels = {
added: { label: 'Added', color: '#238636', bgColor: '#238636' },
deleted: { label: 'Deleted', color: '#f85149', bgColor: '#f85149' },
modified: { label: 'Modified', color: '#d29922', bgColor: '#d29922' },
renamed: { label: 'Renamed', color: '#8b949e', bgColor: '#8b949e' }
}
return labels[status] || labels.modified
}
/**
* Generate side-by-side diff view data
* Useful for split diff view rendering
* @param {Array<DiffLine>} lines - Hunk lines
* @returns {Array<{left: DiffLine|null, right: DiffLine|null}>}
*/
export function toSideBySide(lines) {
const result = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (line.type === 'ctx') {
result.push({ left: line, right: line })
i++
} else if (line.type === 'del') {
// Look ahead for matching add
let j = i + 1
while (j < lines.length && lines[j].type === 'del') {
j++
}
// Collect all deletions
const deletions = lines.slice(i, j)
// Now collect additions
let k = j
while (k < lines.length && lines[k].type === 'add') {
k++
}
const additions = lines.slice(j, k)
// Pair them up
const maxLen = Math.max(deletions.length, additions.length)
for (let m = 0; m < maxLen; m++) {
result.push({
left: deletions[m] || null,
right: additions[m] || null
})
}
i = k
} else if (line.type === 'add') {
result.push({ left: null, right: line })
i++
} else {
i++
}
}
return result
}
export default {
parseDiff,
parseCommitInfo,
getDiffStats,
getStatusLabel,
toSideBySide
}