import { execFile } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; const GIT_TIMEOUT_MS = 30_000; const MAX_BUFFER_BYTES = 10 * 1024 * 1024; const PATCH_COLLECTION_TIMEOUT_MS = 5_000; export const PATCH_CONTEXT_LINES = 3; export const MAX_FILE_PATCH_BYTES = 256 * 1024; export const MAX_TASK_PATCH_BYTES = 1024 * 1024; export const MAX_PATCH_LINES = 20_000; export const MAX_PATCH_FILES = 64; export const MAX_SUMMARY_FILES = 100_000; export const MAX_FILE_PATH_BYTES = 16 * 1024; export const MAX_SUMMARY_METADATA_BYTES = 10 * 1024 * 1024; const DIFF_CONFIG_ARGS = [ "-c", "diff.suppressBlankEmpty=false", "-c", "diff.interHunkContext=0", ]; export interface GitSnapshot { root: string; tree: string; } export type PatchOmissionReason = | "file-too-large" | "task-budget" | "file-limit" | "time-limit" | "error"; export interface FileChange { file: string; status: "A" | "M" | "D"; insertions: number; deletions: number; binary: boolean; patch?: string; patchOmitted?: PatchOmissionReason; } export interface TaskSummary { files: FileChange[]; totalInsertions: number; totalDeletions: number; } interface ComparedFile extends FileChange { oldMode: string; newMode: string; oldObject: string; newObject: string; } interface GitResult { stdout: string; stderr: string; } interface GitBufferResult { stdout: Buffer; stderr: Buffer; } interface GitOptions { timeout?: number; maxBuffer?: number; input?: string | Buffer; } function runGit( cwd: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}, options: GitOptions = {}, ): Promise { return new Promise((resolve, reject) => { const child = execFile( "git", args, { cwd, encoding: "utf8", env: { ...process.env, ...extraEnv }, maxBuffer: options.maxBuffer ?? MAX_BUFFER_BYTES, timeout: options.timeout ?? GIT_TIMEOUT_MS, }, (error, stdout, stderr) => { if (error) { reject(new Error(stderr.trim() || error.message, { cause: error })); return; } resolve({ stdout, stderr }); }, ); if (options.input !== undefined) child.stdin?.end(options.input); }); } function runGitBuffer(cwd: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile( "git", args, { cwd, encoding: "buffer", env: process.env, maxBuffer: MAX_BUFFER_BYTES, timeout: GIT_TIMEOUT_MS, }, (error, stdout, stderr) => { if (error) { reject(new Error(stderr.toString("utf8").trim() || error.message, { cause: error })); return; } resolve({ stdout, stderr }); }, ); }); } function stripFinalLineFeed(output: string): string { return output.endsWith("\n") ? output.slice(0, -1) : output; } async function hasHead(root: string): Promise { try { await runGit(root, ["rev-parse", "--verify", "HEAD^{tree}"]); return true; } catch { return false; } } async function findGitDirectory(root: string): Promise { const result = await runGit(root, ["rev-parse", "--absolute-git-dir"]); return stripFinalLineFeed(result.stdout); } export async function findGitRoot(cwd: string): Promise { try { const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]); return stripFinalLineFeed(result.stdout) || undefined; } catch { return undefined; } } export async function createSnapshot(cwd: string): Promise { const root = await findGitRoot(cwd); if (!root) return undefined; const gitDirectory = await findGitDirectory(root); const temporaryDirectory = await mkdtemp(join(gitDirectory, "pi-task-delta-")); const indexPath = join(temporaryDirectory, "index"); const indexEnv = { GIT_INDEX_FILE: indexPath }; try { if (await hasHead(root)) { await runGit(root, ["read-tree", "HEAD"], indexEnv); } else { await runGit(root, ["read-tree", "--empty"], indexEnv); } await runGit(root, ["add", "-A", "--", "."], indexEnv); const result = await runGit(root, ["write-tree"], indexEnv); return { root, tree: result.stdout.trim() }; } finally { await rm(temporaryDirectory, { recursive: true, force: true }); } } function splitNul(output: Buffer): Buffer[] { const fields: Buffer[] = []; let start = 0; for (let index = 0; index < output.length; index += 1) { if (output[index] !== 0) continue; fields.push(output.subarray(start, index)); start = index + 1; } if (start < output.length) fields.push(output.subarray(start)); return fields; } function displayGitPath(path: Buffer): string { const decoded = path.toString("utf8"); if (Buffer.from(decoded, "utf8").equals(path)) return decoded; let escaped = "\""; for (const byte of path) { if (byte === 0x22 || byte === 0x5c) { escaped += `\\${String.fromCharCode(byte)}`; } else if (byte >= 0x20 && byte <= 0x7e) { escaped += String.fromCharCode(byte); } else { escaped += `\\x${byte.toString(16).padStart(2, "0")}`; } } return `${escaped}\"`; } interface NumstatChange { file: string; insertions: number; deletions: number; binary: boolean; } function parseNumstat(output: Buffer): Map { const changes = new Map(); for (const record of splitNul(output)) { if (record.length === 0) continue; const firstTab = record.indexOf(0x09); const secondTab = record.indexOf(0x09, firstTab + 1); if (firstTab < 0 || secondTab < 0) continue; const added = record.subarray(0, firstTab).toString("ascii"); const deleted = record.subarray(firstTab + 1, secondTab).toString("ascii"); const path = record.subarray(secondTab + 1); const binary = added === "-" || deleted === "-"; changes.set(path.toString("base64"), { file: displayGitPath(path), insertions: binary ? 0 : Number.parseInt(added, 10), deletions: binary ? 0 : Number.parseInt(deleted, 10), binary, }); } return changes; } interface RawChange { status: FileChange["status"]; oldMode: string; newMode: string; oldObject: string; newObject: string; } function parseRawChanges(output: Buffer): Map { const changes = new Map(); const fields = splitNul(output); for (let index = 0; index + 1 < fields.length; index += 2) { const metadata = fields[index]!.toString("ascii"); const path = fields[index + 1]!; const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])$/.exec(metadata); if (!match) continue; const rawStatus = match[5]!; changes.set(path.toString("base64"), { status: rawStatus === "A" || rawStatus === "D" ? rawStatus : "M", oldMode: match[1]!, newMode: match[2]!, oldObject: match[3]!, newObject: match[4]!, }); } return changes; } function serializedPatchBytes(patch: string): number { return Buffer.byteLength(JSON.stringify(patch), "utf8"); } function exceedsPatchLineLimit(patch: string): boolean { let lines = 1; for (const character of patch) { if (character !== "\n") continue; lines += 1; if (lines > MAX_PATCH_LINES) return true; } return false; } function isMaxBufferError(error: unknown): boolean { if (!(error instanceof Error)) return false; const cause = error.cause; return ( error.message.includes("maxBuffer") || (cause instanceof Error && cause.message.includes("maxBuffer")) ); } function isMissingObject(object: string): boolean { return /^0+$/.test(object); } function submodulePatch(file: ComparedFile): string { const oldExists = !isMissingObject(file.oldObject); const newExists = !isMissingObject(file.newObject); const oldRange = oldExists ? "-1" : "-0,0"; const newRange = newExists ? "+1" : "+0,0"; const lines = [`@@ ${oldRange} ${newRange} @@`]; if (oldExists) lines.push(`-Subproject commit ${file.oldObject}`); if (newExists) lines.push(`+Subproject commit ${file.newObject}`); return `${lines.join("\n")}\n`; } async function capturePatches( root: string, files: ComparedFile[], ): Promise { const deadline = Date.now() + PATCH_COLLECTION_TIMEOUT_MS; let attemptedFiles = 0; let taskBytes = 0; let taskBudgetExhausted = false; let emptyBlob: Promise | undefined; const getEmptyBlob = (): Promise => { emptyBlob ??= runGit( root, ["hash-object", "-w", "--stdin"], {}, { input: Buffer.alloc(0) }, ).then((result) => result.stdout.trim()); return emptyBlob; }; for (const file of files) { if (file.binary) continue; if (taskBudgetExhausted) { file.patchOmitted = "task-budget"; continue; } if (attemptedFiles >= MAX_PATCH_FILES) { file.patchOmitted = "file-limit"; continue; } const remainingTime = deadline - Date.now(); if (remainingTime <= 0) { file.patchOmitted = "time-limit"; continue; } attemptedFiles += 1; try { let patch: string; if (file.oldMode === "160000" || file.newMode === "160000") { patch = submodulePatch(file); } else { const oldObject = isMissingObject(file.oldObject) ? await getEmptyBlob() : file.oldObject; const newObject = isMissingObject(file.newObject) ? await getEmptyBlob() : file.newObject; const result = await runGit( root, [ ...DIFF_CONFIG_ARGS, "diff", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "--text", `--unified=${PATCH_CONTEXT_LINES}`, oldObject, newObject, ], {}, { maxBuffer: MAX_FILE_PATCH_BYTES, timeout: Math.min(GIT_TIMEOUT_MS, remainingTime), }, ); patch = result.stdout; } const patchBytes = serializedPatchBytes(patch); if (patchBytes > MAX_FILE_PATCH_BYTES || exceedsPatchLineLimit(patch)) { file.patchOmitted = "file-too-large"; continue; } if (taskBytes + patchBytes > MAX_TASK_PATCH_BYTES) { file.patchOmitted = "task-budget"; taskBudgetExhausted = true; continue; } file.patch = patch; taskBytes += patchBytes; } catch (error) { if (isMaxBufferError(error)) { file.patchOmitted = "file-too-large"; } else if (Date.now() >= deadline) { file.patchOmitted = "time-limit"; } else { file.patchOmitted = "error"; } } } } export async function compareSnapshots( baseline: GitSnapshot, current: GitSnapshot, ): Promise { if (baseline.root !== current.root) return undefined; const diffOptions = [ "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "--ignore-submodules=none", ]; const [numstatResult, rawResult] = await Promise.all([ runGitBuffer(baseline.root, [ ...DIFF_CONFIG_ARGS, "diff", ...diffOptions, "--numstat", "-z", baseline.tree, current.tree, "--", ]), runGitBuffer(baseline.root, [ ...DIFF_CONFIG_ARGS, "diff", ...diffOptions, "--raw", "--abbrev=64", "-z", baseline.tree, current.tree, "--", ]), ]); const numstat = parseNumstat(numstatResult.stdout); const rawChanges = parseRawChanges(rawResult.stdout); const files: ComparedFile[] = []; for (const [pathKey, counts] of numstat) { const raw = rawChanges.get(pathKey); if (!raw) continue; files.push({ ...counts, ...raw }); } files.sort((left, right) => left.file.localeCompare(right.file)); if (files.length === 0) return undefined; await capturePatches(baseline.root, files); const publicFiles: FileChange[] = files.map((file) => ({ file: file.file, status: file.status, insertions: file.insertions, deletions: file.deletions, binary: file.binary, ...(file.patch === undefined ? {} : { patch: file.patch }), ...(file.patchOmitted === undefined ? {} : { patchOmitted: file.patchOmitted }), })); return { files: publicFiles, totalInsertions: publicFiles.reduce((total, file) => total + file.insertions, 0), totalDeletions: publicFiles.reduce((total, file) => total + file.deletions, 0), }; }