OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / diff-parser.js

323 lines · javascript ref:
/**
 * 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
}