UNPKG

reviewit

Version:

A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view

224 lines (223 loc) 9.69 kB
import { simpleGit } from 'simple-git'; import { validateDiffArguments, shortHash, createCommitRangeString } from '../cli/utils.js'; export class GitDiffParser { git; constructor(repoPath = process.cwd()) { this.git = simpleGit(repoPath); } async parseDiff(targetCommitish, baseCommitish, ignoreWhitespace = false) { try { // Validate arguments const validation = validateDiffArguments(targetCommitish, baseCommitish); if (!validation.valid) { throw new Error(validation.error); } let resolvedCommit; let diffArgs; // Handle target special chars (base is always a regular commit) if (targetCommitish === 'working') { // Show unstaged changes (working vs staged) resolvedCommit = 'Working Directory (unstaged changes)'; diffArgs = []; } else if (targetCommitish === 'staged') { // Show staged changes against base commit const baseHash = await this.git.revparse([baseCommitish]); resolvedCommit = `${shortHash(baseHash)} vs Staging Area (staged changes)`; diffArgs = ['--cached', baseCommitish]; } else if (targetCommitish === '.') { // Show all uncommitted changes against base commit const baseHash = await this.git.revparse([baseCommitish]); resolvedCommit = `${shortHash(baseHash)} vs Working Directory (all uncommitted changes)`; diffArgs = [baseCommitish]; } else { // Both are regular commits: standard commit-to-commit comparison const targetHash = await this.git.revparse([targetCommitish]); const baseHash = await this.git.revparse([baseCommitish]); resolvedCommit = createCommitRangeString(shortHash(baseHash), shortHash(targetHash)); diffArgs = [resolvedCommit]; } if (ignoreWhitespace) { diffArgs.push('-w'); } // Ignore external diff-tools to unify output. // https://github.com/yoshiko-pg/reviewit/issues/19 diffArgs.push('--no-ext-diff', '--color=never'); // Add --color=never to ensure plain text output without ANSI escape sequences const diffSummary = await this.git.diffSummary(diffArgs); const diffRaw = await this.git.diff(['--color=never', ...diffArgs]); const files = this.parseUnifiedDiff(diffRaw, diffSummary.files); return { commit: resolvedCommit, files, isEmpty: files.length === 0, }; } catch (error) { throw new Error(`Failed to parse diff for ${targetCommitish} vs ${baseCommitish}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } parseUnifiedDiff(diffText, summary) { const files = []; const fileBlocks = diffText.split(/^diff --git /m).slice(1); for (let i = 0; i < fileBlocks.length; i++) { const block = `diff --git ${fileBlocks[i]}`; const summaryItem = summary[i]; if (!summaryItem) continue; const file = this.parseFileBlock(block, summaryItem); if (file) { files.push(file); } } return files; } parseFileBlock(block, summary) { const lines = block.split('\n'); const headerLine = lines[0]; const pathMatch = headerLine.match(/^diff --git (?:[a-z]\/)?(.+) (?:[a-z]\/)?(.+)$/); if (!pathMatch) return null; const oldPath = pathMatch[1]; const newPath = pathMatch[2]; const path = newPath; let status = 'modified'; // Check for new file mode (added files) const newFileMode = lines.find((line) => line.startsWith('new file mode')); const deletedFileMode = lines.find((line) => line.startsWith('deleted file mode')); // Check for /dev/null which indicates added or deleted files const minusLine = lines.find((line) => line.startsWith('--- ')); const plusLine = lines.find((line) => line.startsWith('+++ ')); if (newFileMode || (minusLine && minusLine.includes('/dev/null'))) { status = 'added'; } else if (deletedFileMode || (plusLine && plusLine.includes('/dev/null'))) { status = 'deleted'; } else if (oldPath !== newPath) { status = 'renamed'; } else if (summary.insertions && !summary.deletions) { status = 'added'; } else if (summary.deletions && !summary.insertions) { status = 'deleted'; } // For binary files, don't try to parse chunks const chunks = summary.binary ? [] : this.parseChunks(lines); return { path, oldPath: oldPath !== newPath ? oldPath : undefined, status, additions: summary.insertions || 0, deletions: summary.deletions || 0, chunks, }; } parseChunks(lines) { const chunks = []; let currentChunk = null; let oldLineNum = 0; let newLineNum = 0; for (const line of lines) { if (line.startsWith('@@')) { if (currentChunk) { chunks.push(currentChunk); } const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/); if (match) { const oldStart = parseInt(match[1]); const oldLines = parseInt(match[2] || '1'); const newStart = parseInt(match[3]); const newLines = parseInt(match[4] || '1'); oldLineNum = oldStart; newLineNum = newStart; currentChunk = { header: line, oldStart, oldLines, newStart, newLines, lines: [], }; } } else if (currentChunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) { const type = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'normal'; const diffLine = { type, content: line.slice(1), oldLineNumber: type !== 'add' ? oldLineNum : undefined, newLineNumber: type !== 'delete' ? newLineNum : undefined, }; currentChunk.lines.push(diffLine); if (type !== 'add') oldLineNum++; if (type !== 'delete') newLineNum++; } } if (currentChunk) { chunks.push(currentChunk); } return chunks; } async validateCommit(commitish) { try { if (commitish === '.' || commitish === 'working' || commitish === 'staged') { // For working directory or staging area, just check if we're in a git repo await this.git.status(); return true; } await this.git.show([commitish, '--name-only']); return true; } catch { return false; } } async getBlobContent(filepath, ref) { try { // For working directory, read directly from filesystem if (ref === 'working' || ref === '.') { const fs = await import('fs'); return fs.readFileSync(filepath); } // For git refs, we need to use child_process to execute git cat-file // to properly handle binary data const { execFileSync } = await import('child_process'); // Handle staged files if (ref === 'staged') { // For staged files, use git show :filepath // Using execFileSync to prevent command injection const buffer = execFileSync('git', ['show', `:${filepath}`], { maxBuffer: 10 * 1024 * 1024, // 10MB limit }); return buffer; } // First, get the blob hash for the file at the given ref // Using execFileSync to prevent command injection const blobHash = execFileSync('git', ['rev-parse', `${ref}:${filepath}`], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, }).trim(); // Then use git cat-file to get the raw binary content // Increase maxBuffer to handle large files (default is 1024*1024 = 1MB) const buffer = execFileSync('git', ['cat-file', 'blob', blobHash], { maxBuffer: 10 * 1024 * 1024, // 10MB limit }); return buffer; } catch (error) { // Check if it's a buffer size error if (error instanceof Error && (error.message.includes('ENOBUFS') || error.message.includes('maxBuffer'))) { throw new Error(`Image file ${filepath} is too large to display (over 10MB limit)`); } throw new Error(`Failed to get blob content for ${filepath} at ${ref}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } }