// prompt-builders.ts — shared prompt builders + rules loader // // Provides buildGroupingPrompt and buildSingleMessagePrompt that wrap the // existing prompt literals (byte-identical to upstream pi-committer) and // optionally append rules from an external file pointed to by prompt_rules_path. // // Also provides loadCommitRules (cached, fail-graceful) and // resetCommitRulesCache for session-lifecycle cache invalidation. // // Type-only import of CommitterConfig from config.ts (erased at runtime → // safe for worker fork, no circular dependency). import { readFileSync } from "node:fs"; import * as path from "node:path"; // --------------------------------------------------------------------------- // Rules cache // --------------------------------------------------------------------------- /** * Module-level cache for loaded commit rules. * - undefined: not loaded yet * - null: no file / unreadable → returns "" * - string: cached content */ let cachedRules: string | null | undefined; let cachedRulesPath: string | null | undefined; // resolved-path cache key; null = "no path" /** * Load commit discipline rules from the file at `promptRulesPath`. * * - Returns "" when path is unset, empty, or unreadable (fail-graceful, no throw). * - Caches per-process (main: per-session; worker: per-fork). * - Relative paths resolved against process.cwd(). */ export function loadCommitRules(promptRulesPath?: string): string { // Cache is keyed by the resolved path so calls with different paths do not // return a stale result (null = "no path" sentinel). const key = promptRulesPath && promptRulesPath.trim() ? path.resolve(promptRulesPath) : null; if (cachedRulesPath === key && cachedRules !== undefined) { return cachedRules ?? ""; } if (key === null) { cachedRulesPath = null; cachedRules = null; return ""; } try { cachedRulesPath = key; cachedRules = readFileSync(key, "utf-8").trim(); return cachedRules; } catch { cachedRules = null; return ""; } } /** * Reset the rules cache. Called from /commit-config reload in the main process. * In the worker, each fork is a fresh process (no reset needed). */ export function resetCommitRulesCache(): void { cachedRules = undefined; cachedRulesPath = undefined; } // --------------------------------------------------------------------------- // Grouping prompt builder // --------------------------------------------------------------------------- /** * Build the grouping-prompt string from the base literal (byte-identical to * upstream) with optional appended rules. * * The base literal is composed with the dynamic file list / diff stat / diff * content using the same assembly the inline code does. When rules are present, * they are appended as a distinct trailing section. */ export function buildGroupingPrompt( allFiles: string[], diffStat: string, truncatedDiff: string, rules: string, ): string { const fileListStr = allFiles.map((f) => ` - ${f}`).join("\n"); const base = [ "You are organizing a git commit. Given the diff below, split the changes into logical commit groups.", "", "Rules:", "- Group related changes together (same feature, same fix, same refactoring, same area of code)", "- Split unrelated changes into separate commits", "- Each commit must use conventional commit format: (): ", "- Type must be one of: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert", "- Scope: use the single most-specific directory for each group (e.g. 'api', 'exposure', 'config'). NEVER comma-join multiple scopes. If files in a group span unrelated directories, OMIT scope.", "- Description: a SHORT imperative phrase summarizing what each group does. Be specific: 'add regression pipeline and tests', not 'update 27 modules'.", "- Max 72 chars per header line (type + scope + description combined).", "- Assign each file to EXACTLY ONE group", "- Cover ALL files listed below in your groups", "", `Changed files (${allFiles.length}):`, fileListStr, "", "Diff stat:", diffStat, "", "Diff content:", truncatedDiff, "", "Output format (replace N with group number):", "--- COMMIT GROUP 1 ---", "(): ", "", "", "Files: , ", "", "--- COMMIT GROUP 2 ---", "...", "", "If all changes belong in one commit, output a single COMMIT GROUP.", ].join("\n"); if (rules && rules.trim()) { return `${base}\n\nAdditional commit discipline rules:\n${rules}`; } return base; } // --------------------------------------------------------------------------- // Single-message prompt builder // --------------------------------------------------------------------------- /** * Build the single-message-prompt string from the base literal with optional * appended rules. Same composition pattern as buildGroupingPrompt. * * The base literal is byte-identical to upstream's inline literal at * index.ts:1238-1266 and async-commit-worker.ts:605-633. */ export function buildSingleMessagePrompt( diffStat: string, truncatedDiff: string, rules: string, ): string { const base = [ "Generate a conventional commit message from this git diff.", "", "Format:", "(): ", "", "", "", "Rules:", "- Type must be one of: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert", "- Scope: use the single most-specific directory that groups the changes (e.g. 'api', 'config', 'exposure'). NEVER comma-join multiple scopes. If files span unrelated directories, OMIT scope entirely.", "- Description: a SHORT imperative phrase summarizing what was done. Be specific: 'add regression pipeline and tests', not 'update 27 modules'.", "- Max 72 chars for the header line (type + scope + description combined).", "- Body: a brief paragraph explaining what changed and why.", "- Output ONLY the commit message, nothing else.", "", "Diff stat:", diffStat, "", "Full diff:", truncatedDiff, ].join("\n"); if (rules && rules.trim()) { return `${base}\n\nAdditional commit discipline rules:\n${rules}`; } return base; }