/** * Scan committed fixes from git history - recovery from interruption * * WHY this module exists: * When prr is interrupted (Ctrl+C, crash, timeout), in-memory state is lost. * But git commits are durable. By scanning commit messages for prr-fix markers, * we can recover which issues were already verified without re-running verifications. * * WHY scan git log instead of state files: * - Git commits can't be corrupted or deleted accidentally * - Works even if state file is missing or outdated * - Provides audit trail of what was actually committed * * USAGE: Called at startup to restore verification state from previous runs. * * **In-process cache (workdir + branch + HEAD):** Repeated calls with the same refs reuse the * parsed comment-id list so recovery does not re-run `git log` / grep when nothing changed. * **WHY:** Pill noted redundant scans; the scan is bounded but still avoidable when setup and * recovery share one HEAD. Tests call **`clearScanCommittedFixesCache()`**. */ import type { SimpleGit } from 'simple-git'; import { debug } from '../logger.js'; /** In-process cache: same workdir + branch + HEAD → same grep scan (pill: avoid redundant git log). */ const committedFixScanCache = new Map(); const MAX_SCAN_CACHE_ENTRIES = 64; function scanCacheKey(workdir: string, branch: string, headSha: string, prBaseBranch?: string): string { const base = prBaseBranch?.trim() ?? ''; return `${workdir}\0${branch}\0${headSha}\0${base}`; } function rememberScanCache(key: string, ids: string[]): void { if (committedFixScanCache.size >= MAX_SCAN_CACHE_ENTRIES) { const firstKey = committedFixScanCache.keys().next().value as string | undefined; if (firstKey !== undefined) committedFixScanCache.delete(firstKey); } committedFixScanCache.set(key, ids); } /** Clear process-wide scan cache (tests or long-lived hosts). */ export function clearScanCommittedFixesCache(): void { committedFixScanCache.clear(); } export interface ScanCommittedFixesOptions { /** When set with headSha, reuse a prior in-process result for this workdir/branch/HEAD. */ workdir?: string; headSha?: string; /** * GitHub PR base branch name (e.g. `develop`, `v2.0.0`). When set, PRR tries `origin/` first * for the `base..branch` log range before falling back to origin/main|master|develop. * WHY: Repos whose default is not main/master/develop used to leave `baseBranch` null in debug and * rely on `-n 100`; using the real PR base matches pill/external audits expecting a proper merge range. */ prBaseBranch?: string; } /** * Scan git commit messages for prr-fix markers to recover verification state * * WHY scan only branch-specific commits: * Using `base..branch` range ensures we only look at commits created in this PR branch. * This prevents false positives from merged commits or commits from other branches. * * WHY try multiple base branches: * Different repos use different default branch names (main, master, develop). * We try common names to maximize compatibility without requiring configuration. * * WHY fallback to last 100 commits: * If we can't find a base branch (rare), limit search to recent history to avoid * scanning thousands of commits. 100 is generous - typical PRs have far fewer. * * WHY use raw() instead of simple-git's log(): * simple-git doesn't support --grep properly, and we need exact control over * the git log command for reliable marker detection. * * WHY preserve original casing: * The state's verifiedFixed array stores IDs in their original case from the API. * We preserve casing from commit messages so markers match on recovery. * * WHY return empty array on error: * Failure to scan is not fatal - we'll just start with no recovered state and * verify everything fresh. Throwing would prevent startup on minor git issues. * * @param git - SimpleGit instance for the repository * @param branch - Current PR branch name * @param opts - Optional cache: pass workdir + headSha to dedupe scans in one process * @returns Array of comment IDs that were previously committed (empty on error) */ export async function scanCommittedFixes( git: SimpleGit, branch: string, opts?: ScanCommittedFixesOptions ): Promise { if (opts?.workdir && opts?.headSha) { const key = scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch); const hit = committedFixScanCache.get(key); if (hit) { debug('scanCommittedFixes (cache hit)', { branch, headSha: opts.headSha.slice(0, 7) }); return [...hit]; } } try { // Find the base branch — PR's GitHub base first, then common default names const baseBranches = ['origin/main', 'origin/master', 'origin/develop']; let baseBranch: string | null = null; const prBase = opts?.prBaseBranch?.trim(); if (prBase) { const prRef = `origin/${prBase}`; try { await git.raw(['rev-parse', '--verify', prRef]); baseBranch = prRef; } catch { // Single-branch clones may not have fetched base yet; fall through to heuristics } } for (const candidate of baseBranches) { if (baseBranch) break; try { await git.raw(['rev-parse', '--verify', candidate]); baseBranch = candidate; break; } catch { // Branch doesn't exist, try next } } // If no common base branch found, fall back to searching all history // WHY limit to 100: Prevents scanning thousands of commits in large repos // WHY still safe: Typical PRs have < 20 commits, 100 is very generous const logArgs = baseBranch ? ['log', '--grep=prr-fix:', '--format=%B', `${baseBranch}..${branch}`] : ['log', '--grep=prr-fix:', '--format=%B', '-n', '100']; debug('scanCommittedFixes', { baseBranch, branch, logArgs }); const logOutput = await git.raw(logArgs); const commentIds: string[] = []; // Parse all prr-fix:ID markers from commit messages // Format: One marker per line: "prr-fix:IC_kwDOAbc123_defGHI" if (logOutput) { const lines = logOutput.split('\n'); for (const line of lines) { const match = line.match(/^prr-fix:(.+)$/); if (match) { // Preserve original casing from commit messages. // WHY NOT lowercase: The state's verifiedFixed array stores IDs in // their original case (from the GitHub API). Lowercasing here causes // case-sensitive includes() checks to miss existing entries, leading // to duplicate IDs accumulating across sessions. commentIds.push(match[1].trim()); } } } // Deduplicate: the same ID can appear in multiple commits // (e.g., re-verified after a push, or re-committed after interruption) const unique = [...new Set(commentIds)]; if (opts?.workdir && opts?.headSha) { rememberScanCache(scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch), unique); } return unique; } catch (error) { // WHY catch and return empty instead of throw: // Scan failure shouldn't prevent startup - we'll just verify everything fresh debug('Failed to scan committed fixes', { error }); return []; } }