/** * Iteration commit logic - commits with prr-fix markers for recovery * * WHY this module exists: * Iteration commits have special requirements (markers for recovery, skip hooks, * specific message format) that don't belong in generic commit operations. * * WHY prr-fix markers: * On restart after interruption (Ctrl+C, crash), we scan git log for these * markers to recover which issues were already verified and committed. This * is more reliable than state files which can be lost or corrupted. * * WHY skip hooks (--no-verify): * Pre-commit hooks are designed for human commits, not automated iterations. * They can block automation (e.g., requiring ticket numbers) or add unwanted * changes (e.g., auto-formatting that conflicts with fixer output). * * USAGE: Called by fix iteration workflow after verification succeeds. */ import type { SimpleGit } from 'simple-git'; import { stageAll, runPreCommitChecks, type CommitResult } from './git-commit-core.js'; import { buildCommitMessage } from './git-commit-message.js'; import { ensureGitIdentity } from './git-merge.js'; import { debug } from '../logger.js'; /** * Create one commit per file for a set of verified issues (cleaner history). * Use when multiple issues were fixed in one iteration — each file gets its own commit. */ export async function commitIterationPerFile( git: SimpleGit, issuesWithDetails: Array<{ commentId: string; filePath: string; comment: string }>, iterationNumber: number ): Promise<{ committedIds: string[]; filesCommitted: number }> { const committedIds: string[] = []; if (issuesWithDetails.length === 0) return { committedIds, filesCommitted: 0 }; await ensureGitIdentity(git); const byFile = new Map(); for (const issue of issuesWithDetails) { const list = byFile.get(issue.filePath) ?? []; list.push(issue); byFile.set(issue.filePath, list); } let filesCommitted = 0; for (const [filePath, issues] of byFile) { await git.add(filePath); await runPreCommitChecks(git); const staged = await git.diff(['--cached', '--name-only']); const stagedFiles = staged ? staged.trim().split('\n').filter(Boolean) : []; if (!stagedFiles.includes(filePath)) continue; const unexpected = stagedFiles.filter((f) => f !== filePath); if (unexpected.length > 0) { debug(`Per-file commit for "${filePath}" found extra staged files — unstaging: ${unexpected.join(', ')}`); for (const extra of unexpected) { try { await git.reset(['HEAD', '--', extra]); } catch { await git.raw(['rm', '--cached', extra]).catch(() => {}); } } } const markers = issues.map((i) => `prr-fix:${i.commentId.toLowerCase()}`).join('\n'); const commitMsg = buildCommitMessage( issues.map((i) => ({ filePath: i.filePath, comment: i.comment })), [] ); const firstLine = commitMsg.split('\n')[0]; const message = [firstLine, '', `Iteration ${iterationNumber}`, '', markers].join('\n'); await git.commit(message, { '--no-verify': null }); for (const i of issues) committedIds.push(i.commentId); filesCommitted++; } return { committedIds, filesCommitted }; } /** * Create a commit for a completed fix iteration with recovery markers * * WHY check hasChanges first: * Prevents "nothing to commit" errors when the fixer didn't actually modify * any files. This can happen if the issue was already fixed or if the fixer * determined no changes were needed. * * WHY return null instead of throwing: * No changes is a valid state, not an error. The caller can decide how to * handle it (e.g., log a message, try a different approach). * * WHY normalize comment IDs to lowercase: * GitHub's GraphQL API sometimes returns IDs with inconsistent casing. * Normalizing ensures we can reliably match markers during recovery. * * WHY include iteration number: * Makes it easy to see the sequence of fix attempts in git log. Also helps * identify which iteration a particular fix came from when debugging. * * @param git - SimpleGit instance for the repository * @param verifiedCommentIds - GitHub comment IDs that were successfully fixed * @param iterationNumber - Current iteration count (for logging/tracking) * @param fixedIssues - Optional details about what was fixed (for better commit message) * @returns Commit result, or null if nothing to commit */ export async function commitIteration( git: SimpleGit, verifiedCommentIds: string[], iterationNumber: number, fixedIssues?: Array<{ filePath: string; comment: string }> ): Promise { // Check if there are changes to commit // WHY: Prevents "nothing to commit" errors if fixer made no changes const status = await git.status(); const hasChanges = !status.isClean(); if (!hasChanges || verifiedCommentIds.length === 0) { return null; // Nothing to commit } await ensureGitIdentity(git); await stageAll(git); await runPreCommitChecks(git); const staged = await git.diff(['--cached', '--name-only']); const stagedFiles = staged ? staged.trim().split('\n').filter(Boolean) : []; if (stagedFiles.length === 0) { return null; // Everything was unstaged by pre-commit checks (e.g. tool artifacts, empty test files) } // Build prr-fix markers for recovery // WHY lowercase: GitHub IDs have inconsistent casing, normalize for reliable matching const markers = verifiedCommentIds .map(id => `prr-fix:${id.toLowerCase()}`) .join('\n'); // Generate a meaningful commit message from the fixed issues // WHY: Better than generic "fix iteration N" - shows what actually changed const commitMsg = fixedIssues && fixedIssues.length > 0 ? buildCommitMessage(fixedIssues, []) : 'fix: address review comments'; const firstLine = commitMsg.split('\n')[0]; // Format: \n\nIteration N\n\nprr-fix:id1\nprr-fix:id2\n... const message = [ firstLine, '', `Iteration ${iterationNumber}`, '', markers, ].join('\n'); // Skip pre-commit hooks for automated commits // WHY --no-verify: Hooks are for humans, can block automation or add unwanted changes const result = await git.commit(message, { '--no-verify': null }); return { hash: result.commit, message, filesChanged: result.summary.changes, stagedFiles, }; // Review: returns commit details to support consistent handling across git operations }