/** * FNXC:CodeOrganization 2026-07-16-00:30: * Pure git/test-output parse helpers peeled from merger.ts (string parsing only). * * FNXC:CodeOrganization 2026-07-16-14:00: * Also hosts getBranchChangedFiles + quoteArg (shell-safe git name-only diff). */ import { execFileSync } from "node:child_process"; const BOUNDED_GIT_DIFF_TIMEOUT_MS = 5_000; const BOUNDED_GIT_DIFF_MAX_BUFFER = 10 * 1024 * 1024; export function parseFailingFilesFromOutput(output: string): string[] { const paths = new Set(); for (const line of output.split("\n")) { // jest/vitest: "FAIL packages/engine/src/__tests__/foo.test.ts" const failMatch = line.match(/^FAIL\s+(\S+)/); if (failMatch && failMatch[1]) { paths.add(failMatch[1]); continue; } // vitest summary: " ❯ packages/engine/src/__tests__/foo.test.ts (2 tests | 1 failed)" const vitestSummaryMatch = line.match(/^\s*[❯>]\s+(\S+\.(?:test|spec)\.[jt]sx?)\s/); if (vitestSummaryMatch && vitestSummaryMatch[1]) { paths.add(vitestSummaryMatch[1]); continue; } // vitest: " × src/__tests__/foo.test.ts > some test name" const crossMatch = line.match(/^\s*[×✕✗]\s+(\S+\.(?:test|spec)\.[jt]sx?)\s/); if (crossMatch && crossMatch[1]) { paths.add(crossMatch[1]); } } return Array.from(paths); } /** Parse `git status -z --porcelain` into a Set of paths. * * Format per entry: `XY \0` where X = staged status, Y = * unstaged status. Renames and copies are special: they emit TWO * NUL-separated entries, `R \0\0` (or `C \0\0`). * We must consume the trailing `` entry without treating it as a * separate path, otherwise observability code over-reports "cleared * paths" with the historical names of renames. */ export function parsePorcelainZ(raw: string): Set { const paths = new Set(); const entries = raw.split("\0"); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (!entry) continue; if (entry.length < 4) continue; const status = entry.slice(0, 2); const path = entry.slice(3); if (!path) continue; paths.add(path); // Rename/copy: the very next entry is the old path — skip it so it // isn't mistaken for an independent dirty path. if (status.charAt(0) === "R" || status.charAt(0) === "C") { i++; } } return paths; } export function parseShortstatSummary(statsOutput: string): { filesChanged: number; insertions: number; deletions: number } { const normalized = statsOutput.trim().replace(/\n/g, " "); const filesMatch = normalized.match(/(\d+) files? changed/); const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/); const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/); return { filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0, insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0, deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0, }; } export function quoteArg(value: string): string { return `"${value.replace(/(["\\$`])/g, "\\$1")}"`; } /** * Get the set of files changed in the branch relative to the base branch. * Uses `git diff --name-only -z ...HEAD` (three-dot range so it * computes the diff from the merge-base, not the current HEAD of baseBranch). * * FNXC:CodeOrganization 2026-07-16-16:00: * Invoke git via execFileSync argv (no shell) so branch names cannot break * quoting on Windows cmd.exe, and parse NUL-delimited paths so whitespace/ * newlines in filenames are preserved. Empty array on git errors (unknown). * * FNXC:EngineAsyncInvariant 2026-07-29-00:00: * This data-dependent git diff remains short plumbing only with an explicit * wall-clock timeout and bounded output. Do not remove either bound or add an * unbounded synchronous shellout on the engine's shared event loop. * * @internal Exported for testing only. */ export function getBranchChangedFiles(rootDir: string, baseBranch: string, branch: string): string[] { try { const headRef = branch === "HEAD" ? "HEAD" : branch; const output = execFileSync( "git", ["diff", "--name-only", "-z", `${baseBranch}...${headRef}`], { cwd: rootDir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: BOUNDED_GIT_DIFF_TIMEOUT_MS, maxBuffer: BOUNDED_GIT_DIFF_MAX_BUFFER, }, ); return String(output).split("\0").map((f) => f.trim()).filter(Boolean); } catch { return []; } }