/** * Core commit operations - basic staging and committing * * WHY this module exists: * Extracted from git/commit.ts to separate fundamental commit operations * from more complex push/retry logic and message formatting. * * WHY these functions are together: * Both functions deal with the basic git commit workflow: stage changes, * then commit them. They are the building blocks used by other modules. * * DESIGN: Simple, focused functions that wrap simple-git with clear * return types. No complex retry logic or timeout handling here. */ import { unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import type { SimpleGit } from 'simple-git'; import { debug } from '../logger.js'; export interface CommitResult { hash: string; message: string; filesChanged: number; /** File paths that were actually staged and committed (relative to repo root). * WHY: Callers scope commit messages to these files so the message doesn't list * issues in files that weren't changed in this commit. */ stagedFiles: string[]; } /** * Patterns that indicate tool markup was accidentally left in source files. * These are the XML-like edit instructions from the LLM-API runner that should * be parsed and applied, never committed as raw text. * * We check for the COMBINATION of + or * to avoid false positives from legitimate XML/HTML code that might use one * of these tag names individually. * * WHY also check TOOL_MARKER_FILES: PRR writes __fix-notes.md etc. for the * fixer; those are tool artifacts, not user code. If the fixer leaves them * staged, we unstage them so the commit only contains real source changes. */ const TOOL_MARKUP_PATTERN = /[\s\S]*?[\s\S]*?<\/search>[\s\S]*?/; const TOOL_MARKER_FILES = [ '__cache-check-needed.md', // PRR tool notes '__fix-notes.md', // PRR tool notes ]; /** * Directories that the fixer should never modify. * These are tool-managed (lessons, state) and any edits to them are accidental. * Exported so other modules (e.g. dismissal-comments) can skip them consistently. * * WHY .prr/: Lessons and state live there. Fixer edits would corrupt the * learning system or state and get auto-reverted, wasting a fix iteration. */ export const PROTECTED_DIRS = ['.prr/', '.split-exec-workdir/']; /** * Matches common test file names so we can reject empty/placeholder test files. * Covers: *.test.ts, *.spec.ts, *_test.ts, test_*.ts, and common extensions. */ const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs)$|_test\.(ts|tsx|js|go|py|rs)$/; function isTestFileName(filePath: string): boolean { if (TEST_FILE_PATTERN.test(filePath)) return true; const base = filePath.split('/').pop() || ''; return /^test_.*\.(ts|tsx|js|py)$/.test(base); } /** Max content length (after trim) to consider "placeholder"; no test keywords => likely accidental. */ const PLACEHOLDER_MAX_LEN = 50; /** Test/assert keywords that indicate real test code. */ const TEST_KEYWORDS = /\b(test|it|describe|expect|assert\.|require\s*\()\s*[\(\"]/; /** * Stage all changes in the repository * * WHY use git add -A instead of git add .: * -A stages all changes including deletions and renames, not just modifications. * This ensures we capture the complete state of the working directory. */ export async function stageAll(git: SimpleGit): Promise { await git.add('-A'); } /** * Create a commit with all current changes * * WHY "squash" in the name: * This function stages and commits all outstanding changes in one operation, * effectively "squashing" the working directory state into a single commit. * The name distinguishes it from more granular commit operations. * * WHY separate title and body: * Follows git best practices - first line is the summary (shows in git log), * body provides additional context (shows in git show). This makes commits * more readable in both compact and detailed views. * * @param git - SimpleGit instance for the repository * @param message - Commit title/summary (first line) * @param body - Optional detailed description * @returns Commit hash, message, and number of files changed; null if nothing to commit */ export async function squashCommit( git: SimpleGit, message: string, body?: string ): Promise { // Stage all changes await stageAll(git); // Pre-commit safety: tool artifacts, empty test files, etc. (see runPreCommitChecks). await runPreCommitChecks(git); // If everything was unstaged (e.g. only .prr/ or tool artifacts), nothing to commit. // WHY: Avoids "nothing to commit" error and prevents empty/no-op commits that // would still trigger push + 300s wait (Everything up-to-date). const staged = await git.diff(['--cached', '--name-only']); if (!staged || staged.trim() === '') { return null; } const stagedFiles = staged.trim().split('\n').filter(Boolean); // Build commit message (title + optional body separated by blank line) const fullMessage = body ? `${message}\n\n${body}` : message; // Commit const result = await git.commit(fullMessage); return { hash: result.commit, message, filesChanged: result.summary.changes, stagedFiles, }; } /** * Run all pre-commit safety checks: unstage tool artifacts and empty/placeholder test files. * Exported so both squashCommit and commitIteration can use it. */ export async function runPreCommitChecks(git: SimpleGit): Promise { await unstageToolArtifacts(git); await unstageEmptyTestFiles(git); } /** * Inspect staged changes for tool markup artifacts and unstage them. * * Checks two things: * 1. Files whose names match known tool-generated patterns (e.g., __cache-check-needed.md) * 2. Files whose staged diff contains raw ... markup * * For any matches, we `git checkout HEAD -- ` to restore the original and * `git rm --cached` for new files. This prevents tool debris from being committed * while still allowing the rest of the changes through. */ async function unstageToolArtifacts(git: SimpleGit): Promise { try { // Get list of staged files const status = await git.status(); const stagedFiles = [ ...status.created, ...status.modified, ...status.renamed.map(r => r.to), ]; if (stagedFiles.length === 0) return; const filesToRevert: string[] = []; const filesToRemove: string[] = []; // New files to unstage entirely for (const file of stagedFiles) { const basename = file.split('/').pop() || ''; // Check protected directories (fixer should never modify these) if (PROTECTED_DIRS.some(dir => file.startsWith(dir))) { debug(`Tool artifact detected (protected dir): ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } continue; } // Check filename patterns if (TOOL_MARKER_FILES.some(pattern => basename === pattern) || (basename.startsWith('__') && basename.endsWith('.md'))) { debug(`Tool artifact detected (filename): ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } continue; // Review: distinguishes between created and modified tool artifacts for appropriate handling } // Check file content for tool markup (only for text-like files) if (/\.(ts|tsx|js|jsx|py|rs|go|md|json|yaml|yml|toml)$/.test(file)) { try { const diff = await git.diff(['--cached', '--', file]); if (TOOL_MARKUP_PATTERN.test(diff)) { debug(`Tool markup detected in staged diff: ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } continue; // Skip JSON block and any other checks for this file } } catch { // If diff fails, skip this file (don't block commit) } } // JSON files: reject any staged content that contains // comments or is invalid JSON if (file.endsWith('.json')) { const diff = await git.diff(['--cached', '--', file]); if (/^\+\s*\/\//m.test(diff)) { debug(`JSON file contains comment syntax: ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } continue; } let stagedContent: string; try { stagedContent = await git.raw(['show', ':0:' + file]); } catch (readErr) { debug(`Could not read staged content for ${file} (binary, missing, or conflicted); skip`, { err: String(readErr) }); continue; } try { if (typeof stagedContent === 'string') { JSON.parse(stagedContent); // Valid JSON can still contain tool markup (e.g. in a string value); check and unstage. if (TOOL_MARKUP_PATTERN.test(stagedContent)) { debug(`Tool markup detected in staged JSON: ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } } } } catch { debug(`Invalid JSON detected in staged file: ${file}`); if (status.created.includes(file)) { filesToRemove.push(file); } else { filesToRevert.push(file); } } continue; } } // Revert modified files to HEAD (removes the tool markup changes) for (const file of filesToRevert) { try { await git.checkout(['HEAD', '--', file]); console.log(` ⚠ Reverted tool markup in ${file}`); } catch { debug(`Failed to revert tool artifact: ${file}`); } } // Remove new files that are tool artifacts for (const file of filesToRemove) { try { await git.raw(['rm', '--cached', file]); console.log(` ⚠ Excluded tool artifact: ${file}`); } catch { debug(`Failed to unstage tool artifact: ${file}`); } } } catch (err) { // Non-fatal: if artifact detection fails, proceed with commit as-is debug('Tool artifact detection failed', { error: String(err) }); } } /** * True if content is empty or looks like a placeholder (e.g. one blank line, no real test code). */ function isEmptyOrPlaceholder(content: string): boolean { const trimmed = content.trim(); if (trimmed.length === 0) return true; if (trimmed.length > PLACEHOLDER_MAX_LEN) return false; if (TEST_KEYWORDS.test(trimmed)) return false; const lines = trimmed.split(/\n/).map(l => l.trim()).filter(Boolean); return lines.length <= 2; } /** * Unstage empty or placeholder test files so they are never committed. * New empty test files are also removed from the working tree so they are not re-staged next run. * * WHY: Fixers or users sometimes add a test file as a placeholder (e.g. test_fail.ts with one * blank line) and commit it by mistake. Review bots then flag "empty test file accidentally committed". */ async function unstageEmptyTestFiles(git: SimpleGit): Promise { try { const status = await git.status(); const stagedFiles = [ ...status.created, ...status.modified, ...status.renamed.map(r => r.to), ]; const testFilesToUnstage: { file: string; isNew: boolean }[] = []; for (const file of stagedFiles) { if (!isTestFileName(file)) continue; let stagedContent: string; try { stagedContent = await git.raw(['show', ':0:' + file]); } catch (readErr) { debug(`Could not read staged content for ${file} (e.g. binary or missing; skip)`, { err: String(readErr) }); continue; } if (typeof stagedContent !== 'string') continue; if (!isEmptyOrPlaceholder(stagedContent)) continue; testFilesToUnstage.push({ file, isNew: status.created.includes(file) }); } if (testFilesToUnstage.length === 0) return; const root = (await git.revparse(['--show-toplevel'])).trim(); for (const { file, isNew } of testFilesToUnstage) { try { if (isNew) { await git.raw(['rm', '--cached', file]); const fullPath = join(root, file); if (existsSync(fullPath)) { unlinkSync(fullPath); } console.log(` ⚠ Excluded empty test file (removed from commit and working tree): ${file}`); } else { await git.checkout(['HEAD', '--', file]); console.log(` ⚠ Reverted empty/placeholder test file to HEAD: ${file}`); } } catch (err) { debug('Failed to unstage empty test file', { file, error: String(err) }); } } } catch (err) { debug('Empty test file detection failed', { error: String(err) }); } }