/** * Git diff and change queries */ import type { SimpleGit } from 'simple-git'; import { debug } from '../logger.js'; /** * Map (path -> (oldLine -> newLine)) from git diff base..head. * WHY: After push, review comment line numbers refer to pre-push code; re-mapping * reduces WRONG_LOCATION when the fixer looks up code at the comment's line. * Only context lines (unchanged) are mapped; deleted/added lines are skipped. */ /** * Pick a git ref for **`base..HEAD`** diffs (line map, PR changed-file list) when the PR base is **`develop`** etc. * WHY: On fork clones **`origin/`** may not exist (fork never had that branch); **`upstream/`** was fetched from the base repo (**`baseRepoCloneUrl`**). Using the wrong remote breaks **`computeLineMapFromDiff`** with “ambiguous argument” / missing ref (output.log audits on eliza fork PRs). */ export async function resolveRemoteTrackingRefForPrBase( git: SimpleGit, prInfo: { baseBranch: string; baseRepoCloneUrl?: string } ): Promise { const b = prInfo.baseBranch?.trim(); if (!b) return 'HEAD~1'; const preferUpstreamFirst = Boolean(prInfo.baseRepoCloneUrl?.trim()); const candidates = preferUpstreamFirst ? ([`upstream/${b}`, `origin/${b}`] as const) : ([`origin/${b}`, `upstream/${b}`] as const); for (const ref of candidates) { try { await git.revparse([ref]); return ref; } catch { /* try next */ } } return candidates[0]; } export async function computeLineMapFromDiff( git: SimpleGit, baseRef: string, headRef: string ): Promise>> { const out = new Map>(); let raw: string; try { raw = await git.raw(['diff', `${baseRef}..${headRef}`]); } catch (err) { debug('Line map diff failed', { baseRef, headRef, error: err instanceof Error ? err.message : String(err) }); return out; } const lines = raw.split('\n'); let path: string | null = null; let oldLine = 0; let newLine = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const diffFile = line.match(/^diff --git a\/.+? b\/(.+)/); if (diffFile) { path = diffFile[1]; continue; } const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); // Note: supports hunk parsing only if a valid path is established from the diff output. if (hunk && path) { oldLine = parseInt(hunk[1], 10); newLine = parseInt(hunk[3], 10); continue; } if (path && (line.startsWith(' ') || line.startsWith('-') || line.startsWith('+'))) { if (line.startsWith(' ') || line.startsWith('-')) { if (line.startsWith(' ')) { let fileMap = out.get(path); if (!fileMap) { fileMap = new Map(); out.set(path, fileMap); } fileMap.set(oldLine, newLine); } oldLine++; } if (line.startsWith(' ') || line.startsWith('+')) newLine++; } } return out; } export async function getChangedFiles(git: SimpleGit): Promise { const status = await git.status(); return [...new Set([ ...status.modified, ...status.created, ...status.not_added, ...status.deleted, ...status.renamed.map((r) => r.to), ])]; } export async function getDiff(git: SimpleGit, file?: string): Promise { if (file) { return git.diff(['--', file]); } return git.diff(); } export async function getDiffForFile(git: SimpleGit, file: string): Promise { try { return await git.diff(['HEAD', '--', file]); } catch { // File might be new (untracked), try --no-index diff try { return await git.diff(['--no-index', '/dev/null', file]); } catch (err) { // Log error but return empty - file may not exist or have permission issues debug('Failed to get diff for file', { file, error: err instanceof Error ? err.message : String(err) }); return ''; } // Note: handles known exit code 1 as a valid case for file differences without failing } } /** Margin (lines) when filtering a diff by line range so the verifier sees enough context. */ const DIFF_FILTER_LINE_MARGIN = 40; /** * Filter a single-file unified diff to only hunks that overlap the given line range. * WHY: When multiple fixes target the same file, the batch verifier gets the same full diff for each; * filtering per issue by line avoids attributing one fix's hunks to another (prompts.log audit). */ export function filterUnifiedDiffByLineRange( diff: string, issueLine: number, margin: number = DIFF_FILTER_LINE_MARGIN ): string { const minLine = Math.max(1, issueLine - margin); const maxLine = issueLine + margin; const lines = diff.split('\n'); const out: string[] = []; let inHeader = true; let keepHunk = false; let hunkLines: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const diffFile = line.match(/^diff --git a\/.+? b\/(.+)/); if (diffFile) { if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines); hunkLines = []; inHeader = true; out.push(line); continue; } if (line.startsWith('---') || line.startsWith('+++')) { out.push(line); continue; } const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (hunk) { if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines); const oldStart = parseInt(hunk[1], 10); const oldCount = parseInt(hunk[2] || '1', 10); const oldEnd = oldStart + oldCount; keepHunk = oldEnd >= minLine && oldStart <= maxLine; hunkLines = [line]; inHeader = false; continue; } if (!inHeader) hunkLines.push(line); } if (hunkLines.length > 0 && keepHunk) out.push(...hunkLines); return out.join('\n'); } export async function hasChanges(git: SimpleGit): Promise { const status = await git.status(); return !status.isClean(); } /** * Detect if a file has been corrupted by automated fix attempts. * * Compares the current working tree version against the base branch version. * Returns { corrupted: true, reason, baseContent } if the file shows signs of corruption: * - Duplicate method/function definitions (≥3 occurrences) * - Brace imbalance growth compared to the base version * - Tool-artifact remnants (search/replace XML markup) * - Diff from base has grown significantly (>2x) * * @param baseBranch - The base branch to compare against (e.g. "origin/dev") * @returns { corrupted: false } if file is healthy, or { corrupted: true, reason?: string, baseContent?: string } if corrupted */ export async function detectFileCorruption( git: SimpleGit, filePath: string, baseBranch: string ): Promise<{ corrupted: boolean; reason?: string; baseContent?: string }> { try { // Get the base branch version of the file let baseContent: string; try { baseContent = await git.show([`${baseBranch}:${filePath}`]); } catch { // File doesn't exist in base branch — can't be corrupted by us return { corrupted: false }; } // Get current working tree content let currentContent: string; try { const { readFileSync } = await import('fs'); const { resolve } = await import('path'); const cwd = await git.revparse(['--show-toplevel']); currentContent = readFileSync(resolve(cwd.trim(), filePath), 'utf-8'); } catch { return { corrupted: false }; } const baseLines = baseContent.split('\n').length; const currentLines = currentContent.split('\n').length; const currentLower = currentContent.toLowerCase(); // Heuristic 1: Duplicate method/function definitions // A sign of layered failed search/replace attempts // Note: Only match `(` not `<` to avoid false positives on generic type exports like `export type Foo` const methodPattern = /(?:public|private|protected|async|export)\s+(?:function\s+)?(\w+)\s*\(/g; const methodCounts = new Map(); let match; while ((match = methodPattern.exec(currentContent)) !== null) { const name = match[1]; methodCounts.set(name, (methodCounts.get(name) || 0) + 1); } const duplicateMethods = [...methodCounts.entries()].filter(([, count]) => count >= 3); if (duplicateMethods.length > 0) { const names = duplicateMethods.map(([name, count]) => `${name}(×${count})`).join(', '); return { corrupted: true, reason: `Duplicate method definitions detected: ${names}`, baseContent, }; } // Heuristic 2: Orphaned code indicators // Unbalanced braces suggest structural damage const openBraces = (currentContent.match(/\{/g) || []).length; const closeBraces = (currentContent.match(/\}/g) || []).length; const braceImbalance = Math.abs(openBraces - closeBraces); const baseBraceOpen = (baseContent.match(/\{/g) || []).length; const baseBraceClose = (baseContent.match(/\}/g) || []).length; const baseBraceImbalance = Math.abs(baseBraceOpen - baseBraceClose); if (braceImbalance > baseBraceImbalance + 3) { return { corrupted: true, reason: `Structural damage: brace imbalance grew from ${baseBraceImbalance} to ${braceImbalance}`, baseContent, }; } // Heuristic 3: Tool artifact remnants inside the code if (currentLower.includes('') || currentLower.includes('') || currentLower.includes('') || currentLower.includes(' baseLines * 2) { return { corrupted: true, reason: `File length has more than doubled (${baseLines} → ${currentLines} lines)`, baseContent, }; } return { corrupted: false }; } catch (err) { debug('Corruption detection failed', { filePath, error: err instanceof Error ? err.message : String(err) }); return { corrupted: false }; } }