/** * 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, formatNumber, warn } from '../logger.js'; /** Must match **`FORK_PR_BASE_REMOTE`** in **`git-conflicts.ts`** (fork PR base remote name). */const GIT_SCAN_FORK_BASE_REMOTE = 'upstream'; /** One warning per process per workdir+reason when merge base for prr-fix scan is missing (pill-output #559). */const warnedScanBaseFallback = new Set<string>();/** One warning per process per workdir when git log --grep scan throws (non-fatal degrade). */const warnedScanRawFailure = new Set<string>(); function scanDegradeWarnKey(workdir: string | undefined, tag: string): string { return `${workdir ?? '?'}\0${tag}`;} /** In-process cache: same workdir + branch + HEAD → same grep scan (pill: avoid redundant git log). */const committedFixScanCache = new Map<string, string[]>();const MAX_SCAN_CACHE_ENTRIES = 64; /** * Include resolved merge base (or `n100` when using recent-commit cap) so two clones reusing the * same workdir path cannot share a cache entry when fallback picks different bases (pill-output). */function scanCacheKey( workdir: string, branch: string, headSha: string, prBaseBranch: string | undefined, resolvedBaseLabel: string,): string { const base = prBaseBranch?.trim() ?? ''; return `${workdir}\0${branch}\0${headSha}\0${base}\0${resolvedBaseLabel}`;} /** * Resolve **`upstream/<prBase>`** then **`origin/<prBase>`** (when fork recovery), else **`origin/<prBase>`**, * then first existing of **`origin/main|master|develop`** for **`base..branch`** `git log` range. */async function resolveScanBaseBranch( git: SimpleGit, prBaseBranch?: string, preferUpstreamForPrBase?: boolean): Promise<string | null> { const prBase = prBaseBranch?.trim(); if (prBase) { if (preferUpstreamForPrBase) { const upRef = `${GIT_SCAN_FORK_BASE_REMOTE}/${prBase}`; try { await git.raw(['rev-parse', '--verify', upRef]); return upRef; } catch { /* upstream missing or not fetched — try origin */ } } const prRef = `origin/${prBase}`; try { await git.raw(['rev-parse', '--verify', prRef]); return prRef; } catch { /* fall through — base branch may not be fetched yet */ } } for (const candidate of ['origin/main', 'origin/master', 'origin/develop'] as const) { try { await git.raw(['rev-parse', '--verify', candidate]); return candidate; } catch { /* try next */ } } return null;} 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(); warnedScanBaseFallback.clear(); warnedScanRawFailure.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/<name>` 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; /** * When true (fork PR with **`base.repo` ≠ head**), **`resolveScanBaseBranch`** prefers **`upstream/<prBase>`** * so **`prr-fix:`** recovery matches GitHub’s merge base. Requires **`upstream`** ref (see setup prefetch). */ useUpstreamPrBaseForGitRecovery?: boolean;} /** * 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<string[]> { let resolvedBase: string | null = null; try { resolvedBase = await resolveScanBaseBranch(git, opts?.prBaseBranch, opts?.useUpstreamPrBaseForGitRecovery); } catch (error) { debug('resolveScanBaseBranch failed', { error }); resolvedBase = null; } const cacheKeySuffix = resolvedBase ?? 'n100'; if (resolvedBase === null) { const prBase = opts?.prBaseBranch?.trim(); const tag = prBase ? `missing-base:pr:${prBase}` : 'missing-base:no-origin-default'; const wk = scanDegradeWarnKey(opts?.workdir, tag); if (!warnedScanBaseFallback.has(wk)) { warnedScanBaseFallback.add(wk); if (prBase) { warn( `[PRR] Git recovery scan: could not resolve \`origin/${prBase}\` or a default branch ref (main/master/develop). Using last ${formatNumber(100)} commits instead of \`base..HEAD\` — fetch the PR base with additionalBranches if needed; older \`prr-fix:\` markers may be missed.`, ); } else { warn( `[PRR] Git recovery scan: no \`origin/main\`, \`origin/master\`, or \`origin/develop\` ref — using last ${formatNumber(100)} commits for \`prr-fix:\` recovery (typical of shallow/single-branch clones).`, ); } } } if (opts?.workdir && opts?.headSha) { const key = scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch, cacheKeySuffix); const hit = committedFixScanCache.get(key); if (hit) { debug('scanCommittedFixes (cache hit)', { branch, headSha: opts.headSha.slice(0, 7), resolvedBase: cacheKeySuffix, }); return [...hit]; } } try { const baseBranch = resolvedBase; // 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 prr-fix:ID markers (multiple per line for squash-style messages; pill-output). if (logOutput) { const lines = logOutput.split('\n'); for (const line of lines) { const markerRe = /prr-fix:(\S+)/g; let m: RegExpExecArray | null; while ((m = markerRe.exec(line)) !== null) { commentIds.push(m[1]!.trim()); } } } // Deduplicate: the same ID can appear in multiple commits const unique = [...new Set(commentIds)]; if (opts?.workdir && opts?.headSha) { rememberScanCache( scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch, cacheKeySuffix), 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 const wk = scanDegradeWarnKey(opts?.workdir, 'log-failed'); if (!warnedScanRawFailure.has(wk)) { warnedScanRawFailure.add(wk); const detail = error instanceof Error ? error.message : String(error); warn( `[PRR] Git recovery scan failed (${detail}) — continuing without recovered prr-fix markers. Next verification may re-run for issues fixed in prior commits.`, ); } debug('Failed to scan committed fixes', { error }); return []; }}
/**
* 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, formatNumber, warn } from '../logger.js';
/** Must match **`FORK_PR_BASE_REMOTE`** in **`git-conflicts.ts`** (fork PR base remote name). */
const GIT_SCAN_FORK_BASE_REMOTE = 'upstream';
/** One warning per process per workdir+reason when merge base for prr-fix scan is missing (pill-output #559). */
const warnedScanBaseFallback = new Set<string>();
/** One warning per process per workdir when git log --grep scan throws (non-fatal degrade). */
const warnedScanRawFailure = new Set<string>();
function scanDegradeWarnKey(workdir: string | undefined, tag: string): string {
return `${workdir ?? '?'}\0${tag}`;
}
/** In-process cache: same workdir + branch + HEAD → same grep scan (pill: avoid redundant git log). */
const committedFixScanCache = new Map<string, string[]>();
const MAX_SCAN_CACHE_ENTRIES = 64;
/**
* Include resolved merge base (or `n100` when using recent-commit cap) so two clones reusing the
* same workdir path cannot share a cache entry when fallback picks different bases (pill-output).
*/
function scanCacheKey(
workdir: string,
branch: string,
headSha: string,
prBaseBranch: string | undefined,
resolvedBaseLabel: string,
): string {
const base = prBaseBranch?.trim() ?? '';
return `${workdir}\0${branch}\0${headSha}\0${base}\0${resolvedBaseLabel}`;
}
/**
* Resolve **`upstream/<prBase>`** then **`origin/<prBase>`** (when fork recovery), else **`origin/<prBase>`**,
* then first existing of **`origin/main|master|develop`** for **`base..branch`** `git log` range.
*/
async function resolveScanBaseBranch(
git: SimpleGit,
prBaseBranch?: string,
preferUpstreamForPrBase?: boolean
): Promise<string | null> {
const prBase = prBaseBranch?.trim();
if (prBase) {
if (preferUpstreamForPrBase) {
const upRef = `${GIT_SCAN_FORK_BASE_REMOTE}/${prBase}`;
try {
await git.raw(['rev-parse', '--verify', upRef]);
return upRef;
} catch {
/* upstream missing or not fetched — try origin */
}
}
const prRef = `origin/${prBase}`;
try {
await git.raw(['rev-parse', '--verify', prRef]);
return prRef;
} catch {
/* fall through — base branch may not be fetched yet */
}
}
for (const candidate of ['origin/main', 'origin/master', 'origin/develop'] as const) {
try {
await git.raw(['rev-parse', '--verify', candidate]);
return candidate;
} catch {
/* try next */
}
}
return null;
}
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();
warnedScanBaseFallback.clear();
warnedScanRawFailure.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/<name>` 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;
/**
* When true (fork PR with **`base.repo` ≠ head**), **`resolveScanBaseBranch`** prefers **`upstream/<prBase>`**
* so **`prr-fix:`** recovery matches GitHub’s merge base. Requires **`upstream`** ref (see setup prefetch).
*/
useUpstreamPrBaseForGitRecovery?: boolean;
}
/**
* 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<string[]> {
let resolvedBase: string | null = null;
try {
resolvedBase = await resolveScanBaseBranch(git, opts?.prBaseBranch, opts?.useUpstreamPrBaseForGitRecovery);
} catch (error) {
debug('resolveScanBaseBranch failed', { error });
resolvedBase = null;
}
const cacheKeySuffix = resolvedBase ?? 'n100';
if (resolvedBase === null) {
const prBase = opts?.prBaseBranch?.trim();
const tag = prBase ? `missing-base:pr:${prBase}` : 'missing-base:no-origin-default';
const wk = scanDegradeWarnKey(opts?.workdir, tag);
if (!warnedScanBaseFallback.has(wk)) {
warnedScanBaseFallback.add(wk);
if (prBase) {
warn(
`[PRR] Git recovery scan: could not resolve \`origin/${prBase}\` or a default branch ref (main/master/develop). Using last ${formatNumber(100)} commits instead of \`base..HEAD\` — fetch the PR base with additionalBranches if needed; older \`prr-fix:\` markers may be missed.`,
);
} else {
warn(
`[PRR] Git recovery scan: no \`origin/main\`, \`origin/master\`, or \`origin/develop\` ref — using last ${formatNumber(100)} commits for \`prr-fix:\` recovery (typical of shallow/single-branch clones).`,
);
}
}
}
if (opts?.workdir && opts?.headSha) {
const key = scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch, cacheKeySuffix);
const hit = committedFixScanCache.get(key);
if (hit) {
debug('scanCommittedFixes (cache hit)', {
branch,
headSha: opts.headSha.slice(0, 7),
resolvedBase: cacheKeySuffix,
});
return [...hit];
}
}
try {
const baseBranch = resolvedBase;
// 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 prr-fix:ID markers (multiple per line for squash-style messages; pill-output).
if (logOutput) {
const lines = logOutput.split('\n');
for (const line of lines) {
const markerRe = /prr-fix:(\S+)/g;
let m: RegExpExecArray | null;
while ((m = markerRe.exec(line)) !== null) {
commentIds.push(m[1]!.trim());
}
}
}
// Deduplicate: the same ID can appear in multiple commits
const unique = [...new Set(commentIds)];
if (opts?.workdir && opts?.headSha) {
rememberScanCache(
scanCacheKey(opts.workdir, branch, opts.headSha, opts.prBaseBranch, cacheKeySuffix),
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
const wk = scanDegradeWarnKey(opts?.workdir, 'log-failed');
if (!warnedScanRawFailure.has(wk)) {
warnedScanRawFailure.add(wk);
const detail = error instanceof Error ? error.message : String(error);
warn(
`[PRR] Git recovery scan failed (${detail}) — continuing without recovered prr-fix markers. Next verification may re-run for issues fixed in prior commits.`,
);
}
debug('Failed to scan committed fixes', { error });
return [];
}
}