/** Parse unified git diffs into per-file hunks with old/new line maps. */ const FILE_HEADER = /^diff --git "?a\/(.+?)"? "?b\/(.+?)"?$/; const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; function stripPrefixQuotes(value) { if (value.startsWith('"') && value.endsWith('"')) { return value.slice(1, -1).replace(/\\(.)/g, "$1"); } return value; } /** * Parse a unified diff string. * Returns a Map of newPath -> { oldPath, status, hunks: [{ oldStart, oldLines, * newStart, newLines, newLineNumbers: number[], lines: string[] }] }. * `newLineNumbers[i]` is the new-file line number for hunk line i, or 0 for * removed lines / "\ No newline" markers. */ export function parseUnifiedDiff(diffText) { const files = new Map(); if (!diffText) return files; let current = null; let hunk = null; let newLine = 0; let oldLine = 0; for (const raw of diffText.split("\n")) { const header = FILE_HEADER.exec(raw); if (header) { const newPath = stripPrefixQuotes(header[2]); current = { oldPath: stripPrefixQuotes(header[1]), newPath, status: "modified", hunks: [], }; files.set(newPath, current); hunk = null; continue; } if (!current) continue; if (raw.startsWith("new file mode")) { current.status = "added"; continue; } if (raw.startsWith("deleted file mode")) { current.status = "deleted"; continue; } if (raw.startsWith("rename to ")) { current.newPath = stripPrefixQuotes(raw.slice(10).trim()); continue; } if (raw.startsWith("--- ") || raw.startsWith("+++ ") || raw.startsWith("index ") || raw.startsWith("similarity index") || raw.startsWith("rename from") || raw.startsWith("old mode") || raw.startsWith("new mode")) { continue; } const h = HUNK_HEADER.exec(raw); if (h) { oldLine = Number(h[1]); newLine = Number(h[3]); hunk = { oldStart: oldLine, oldLines: h[2] === undefined ? 1 : Number(h[2]), newStart: newLine, newLines: h[4] === undefined ? 1 : Number(h[4]), newLineNumbers: [], lines: [], }; current.hunks.push(hunk); continue; } if (!hunk) continue; hunk.lines.push(raw); if (raw.startsWith("+")) { hunk.newLineNumbers.push(newLine); newLine += 1; } else if (raw.startsWith("-")) { hunk.newLineNumbers.push(0); oldLine += 1; } else if (raw.startsWith("\\")) { hunk.newLineNumbers.push(0); } else { hunk.newLineNumbers.push(newLine); newLine += 1; oldLine += 1; } } return files; } /** All new-file line numbers present in the diff for a given path. */ export function addedLineNumbers(fileEntry) { const added = new Set(); for (const hunk of fileEntry.hunks) { for (let i = 0; i < hunk.lines.length; i += 1) { if (hunk.lines[i].startsWith("+")) { const line = hunk.newLineNumbers[i]; if (line > 0) added.add(line); } } } return added; } /** All new-file line numbers in any hunk (added + context) for a path. */ export function diffLineNumbers(fileEntry) { const lines = new Set(); for (const hunk of fileEntry.hunks) { for (let i = 0; i < hunk.lines.length; i += 1) { const line = hunk.newLineNumbers[i]; if (line > 0) lines.add(line); } } return lines; } /** * Choose the best inline anchor for a finding. * Prefers a line inside the diff (Gitea renders those inline); falls back to * the start line so the finding is still recorded on the pull request. */ export function pickAnchorLine(fileEntry, startLine, endLine) { const inDiff = diffLineNumbers(fileEntry); const added = addedLineNumbers(fileEntry); const lo = Math.max(1, Math.min(startLine || endLine || 1, endLine || startLine || 1)); const hi = Math.max(startLine || endLine || 1, endLine || startLine || 1); for (let line = lo; line <= hi; line += 1) { if (added.has(line)) return { line, inDiff: true }; } for (let line = lo; line <= hi; line += 1) { if (inDiff.has(line)) return { line, inDiff: true }; } return { line: lo, inDiff: false }; }