export type DiffFileStatus = 'added' | 'deleted' | 'modified' | 'renamed'; export type ParsedDiffFile = { id: string; oldPath: string; newPath: string; /** Path to show in the UI (new path, or old path for deletions). */ displayPath: string; status: DiffFileStatus; additions: number; deletions: number; isBinary: boolean; /** The full per-file unified diff section, fed as-is to the diff renderer. */ hunkText: string; }; const stripABPrefix = (value: string) => value.replace(/^[ab]\//, ''); /** * Splits a raw unified git diff into structured, per-file entries. Replaces the * "one giant blob" rendering: each file can be shown, counted, and highlighted * independently. */ export const parseDiffFiles = (diff: string | undefined): ParsedDiffFile[] => { if (!diff?.trim()) return []; const sections: string[][] = []; let current: string[] | null = null; for (const line of diff.split('\n')) { if (line.startsWith('diff --git ')) { if (current) sections.push(current); current = [line]; continue; } current?.push(line); } if (current) sections.push(current); return sections.map((sectionLines, index) => { const header = sectionLines[0] ?? ''; const gitMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(header); let oldPath = gitMatch?.[1] ?? ''; let newPath = gitMatch?.[2] ?? oldPath; let status: DiffFileStatus = 'modified'; let isBinary = false; let additions = 0; let deletions = 0; let renameFrom = ''; let renameTo = ''; for (const line of sectionLines) { if (line.startsWith('new file mode')) status = 'added'; else if (line.startsWith('deleted file mode')) status = 'deleted'; else if (line.startsWith('rename from ')) { renameFrom = line.slice('rename from '.length); } else if (line.startsWith('rename to ')) { renameTo = line.slice('rename to '.length); } else if ( line.startsWith('Binary files') || line.startsWith('GIT binary patch') ) { isBinary = true; } else if (line.startsWith('--- ')) { const value = line.slice(4).trim(); if (value !== '/dev/null') oldPath = stripABPrefix(value); } else if (line.startsWith('+++ ')) { const value = line.slice(4).trim(); if (value !== '/dev/null') newPath = stripABPrefix(value); } else if (line.startsWith('+') && !line.startsWith('+++')) { additions += 1; } else if (line.startsWith('-') && !line.startsWith('---')) { deletions += 1; } } if (renameFrom || renameTo) { status = 'renamed'; oldPath = renameFrom || oldPath; newPath = renameTo || newPath; } const displayPath = status === 'deleted' ? oldPath : newPath; return { id: `${index}-${displayPath}`, oldPath, newPath, displayPath, status, additions, deletions, isBinary, hunkText: sectionLines.join('\n'), }; }); }; /** Returns the single parsed file matching a path, if present in the diff. */ export const parseDiffFileForPath = ( diff: string | undefined, filePath: string, ): ParsedDiffFile | undefined => { const normalized = filePath.replace(/^\.\/+/, ''); return parseDiffFiles(diff).find( (file) => file.displayPath === normalized || file.newPath === normalized || file.oldPath === normalized, ); }; export const extractFileDiff = (diff: string | undefined, filePath: string) => { if (!diff?.trim() || filePath === '.') return ''; const lines = diff.split('\n'); const sections: string[][] = []; let current: string[] | null = null; for (const line of lines) { if (line.startsWith('diff --git ')) { if (current) sections.push(current); current = [line]; continue; } current?.push(line); } if (current) sections.push(current); const normalizedPath = filePath.replace(/^\.\/+/, ''); const section = sections.find((item) => { const header = item[0] ?? ''; return ( header.includes(` a/${normalizedPath} `) || header.endsWith(` a/${normalizedPath}`) || header.includes(` b/${normalizedPath}`) || header.endsWith(` b/${normalizedPath}`) ); }); return section?.join('\n') ?? ''; };