Spaces:
Paused
Paused
| /** | |
| * Shared diff parsing utilities. | |
| * | |
| * Extracted from commit-worktree-dialog, discard-worktree-changes-dialog, | |
| * stash-changes-dialog and git-diff-panel to eliminate duplication. | |
| */ | |
| export interface ParsedDiffHunk { | |
| header: string; | |
| lines: { | |
| type: 'context' | 'addition' | 'deletion' | 'header'; | |
| content: string; | |
| lineNumber?: { old?: number; new?: number }; | |
| }[]; | |
| } | |
| export interface ParsedFileDiff { | |
| filePath: string; | |
| hunks: ParsedDiffHunk[]; | |
| isNew?: boolean; | |
| isDeleted?: boolean; | |
| isRenamed?: boolean; | |
| /** Pre-computed count of added lines across all hunks */ | |
| additions: number; | |
| /** Pre-computed count of deleted lines across all hunks */ | |
| deletions: number; | |
| } | |
| /** | |
| * Parse unified diff format into structured data. | |
| * | |
| * Note: The regex `diff --git a\/(.*?) b\/(.*)` uses a non-greedy match for | |
| * the `a/` path and a greedy match for `b/`. This can mis-handle paths that | |
| * literally contain " b/" or are quoted by git. In practice this covers the | |
| * vast majority of real-world paths; exotic cases will fall back to "unknown". | |
| */ | |
| export function parseDiff(diffText: string): ParsedFileDiff[] { | |
| if (!diffText) return []; | |
| const files: ParsedFileDiff[] = []; | |
| const lines = diffText.split('\n'); | |
| let currentFile: ParsedFileDiff | null = null; | |
| let currentHunk: ParsedDiffHunk | null = null; | |
| let oldLineNum = 0; | |
| let newLineNum = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| if (line.startsWith('diff --git')) { | |
| if (currentFile) { | |
| if (currentHunk) currentFile.hunks.push(currentHunk); | |
| files.push(currentFile); | |
| } | |
| const match = line.match(/diff --git a\/(.*?) b\/(.*)/); | |
| currentFile = { | |
| filePath: match ? match[2] : 'unknown', | |
| hunks: [], | |
| additions: 0, | |
| deletions: 0, | |
| }; | |
| currentHunk = null; | |
| continue; | |
| } | |
| if (line.startsWith('new file mode')) { | |
| if (currentFile) currentFile.isNew = true; | |
| continue; | |
| } | |
| if (line.startsWith('deleted file mode')) { | |
| if (currentFile) currentFile.isDeleted = true; | |
| continue; | |
| } | |
| if (line.startsWith('rename from') || line.startsWith('rename to')) { | |
| if (currentFile) currentFile.isRenamed = true; | |
| continue; | |
| } | |
| if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) { | |
| continue; | |
| } | |
| if (line.startsWith('@@')) { | |
| if (currentHunk && currentFile) currentFile.hunks.push(currentHunk); | |
| const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1; | |
| newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1; | |
| currentHunk = { | |
| header: line, | |
| lines: [{ type: 'header', content: line }], | |
| }; | |
| continue; | |
| } | |
| if (currentHunk) { | |
| // Skip trailing empty line produced by split('\n') to avoid phantom context line | |
| if (line === '' && i === lines.length - 1) { | |
| continue; | |
| } | |
| if (line.startsWith('+')) { | |
| currentHunk.lines.push({ | |
| type: 'addition', | |
| content: line.substring(1), | |
| lineNumber: { new: newLineNum }, | |
| }); | |
| newLineNum++; | |
| if (currentFile) currentFile.additions++; | |
| } else if (line.startsWith('-')) { | |
| currentHunk.lines.push({ | |
| type: 'deletion', | |
| content: line.substring(1), | |
| lineNumber: { old: oldLineNum }, | |
| }); | |
| oldLineNum++; | |
| if (currentFile) currentFile.deletions++; | |
| } else if (line.startsWith(' ') || line === '') { | |
| currentHunk.lines.push({ | |
| type: 'context', | |
| content: line.substring(1) || '', | |
| lineNumber: { old: oldLineNum, new: newLineNum }, | |
| }); | |
| oldLineNum++; | |
| newLineNum++; | |
| } | |
| } | |
| } | |
| if (currentFile) { | |
| if (currentHunk) currentFile.hunks.push(currentHunk); | |
| files.push(currentFile); | |
| } | |
| return files; | |
| } | |
| /** | |
| * Reconstruct old (original) and new (modified) file content from a single-file | |
| * unified diff string. Used by the CodeMirror merge diff viewer which needs | |
| * both document versions to compute inline highlighting. | |
| * | |
| * For new files (entire content is additions), oldContent will be empty. | |
| * For deleted files (entire content is deletions), newContent will be empty. | |
| */ | |
| export function reconstructFilesFromDiff(diffText: string): { | |
| oldContent: string; | |
| newContent: string; | |
| } { | |
| if (!diffText) return { oldContent: '', newContent: '' }; | |
| const lines = diffText.split('\n'); | |
| const oldLines: string[] = []; | |
| const newLines: string[] = []; | |
| let inHunk = false; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| // Skip diff header lines | |
| if ( | |
| line.startsWith('diff --git') || | |
| line.startsWith('index ') || | |
| line.startsWith('--- ') || | |
| line.startsWith('+++ ') || | |
| line.startsWith('new file mode') || | |
| line.startsWith('deleted file mode') || | |
| line.startsWith('rename from') || | |
| line.startsWith('rename to') || | |
| line.startsWith('similarity index') || | |
| line.startsWith('old mode') || | |
| line.startsWith('new mode') | |
| ) { | |
| continue; | |
| } | |
| // Hunk header | |
| if (line.startsWith('@@')) { | |
| inHunk = true; | |
| continue; | |
| } | |
| if (!inHunk) continue; | |
| // Skip trailing empty line produced by split('\n') | |
| if (line === '' && i === lines.length - 1) { | |
| continue; | |
| } | |
| // "\ No newline at end of file" marker | |
| if (line.startsWith('\\')) { | |
| continue; | |
| } | |
| if (line.startsWith('+')) { | |
| newLines.push(line.substring(1)); | |
| } else if (line.startsWith('-')) { | |
| oldLines.push(line.substring(1)); | |
| } else { | |
| // Context line (starts with space or is empty within hunk) | |
| const content = line.startsWith(' ') ? line.substring(1) : line; | |
| oldLines.push(content); | |
| newLines.push(content); | |
| } | |
| } | |
| return { | |
| oldContent: oldLines.join('\n'), | |
| newContent: newLines.join('\n'), | |
| }; | |
| } | |
| /** | |
| * Split a combined multi-file diff string into per-file diff strings. | |
| * Each entry in the returned array is a complete diff block for a single file. | |
| */ | |
| export function splitDiffByFile( | |
| combinedDiff: string | |
| ): { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] { | |
| if (!combinedDiff) return []; | |
| const results: { filePath: string; diff: string; isNew: boolean; isDeleted: boolean }[] = []; | |
| const lines = combinedDiff.split('\n'); | |
| let currentLines: string[] = []; | |
| let currentFilePath = ''; | |
| let currentIsNew = false; | |
| let currentIsDeleted = false; | |
| for (const line of lines) { | |
| if (line.startsWith('diff --git')) { | |
| // Push previous file if exists | |
| if (currentLines.length > 0 && currentFilePath) { | |
| results.push({ | |
| filePath: currentFilePath, | |
| diff: currentLines.join('\n'), | |
| isNew: currentIsNew, | |
| isDeleted: currentIsDeleted, | |
| }); | |
| } | |
| currentLines = [line]; | |
| const match = line.match(/diff --git a\/(.*?) b\/(.*)/); | |
| currentFilePath = match ? match[2] : 'unknown'; | |
| currentIsNew = false; | |
| currentIsDeleted = false; | |
| } else { | |
| if (line.startsWith('new file mode')) currentIsNew = true; | |
| if (line.startsWith('deleted file mode')) currentIsDeleted = true; | |
| currentLines.push(line); | |
| } | |
| } | |
| // Push last file | |
| if (currentLines.length > 0 && currentFilePath) { | |
| results.push({ | |
| filePath: currentFilePath, | |
| diff: currentLines.join('\n'), | |
| isNew: currentIsNew, | |
| isDeleted: currentIsDeleted, | |
| }); | |
| } | |
| return results; | |
| } | |