import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, join } from "node:path"; import type { DiffLine, Hunk, Snapshot, Source } from "./types"; export const MAX_PATCH_BYTES: number = 256 * 1024; const MAX_METADATA_BYTES = 1024 * 1024; const MAX_STDERR_BYTES = 64 * 1024; const MAX_CHANGES = 2_048; const MAX_PATHSPEC_CHARACTERS = 12_000; const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*$/; const MAX_GIT_IDENTITY_BYTES = 32 * 1024; const GIT_IDENTITY_TIMEOUT_MS = 2_000; let cachedGitCommand: { key: string; command: string } | undefined; type Change = { status: string; path: string; oldPath?: string; }; type ParsedBlock = { hunks: Array>; binary: boolean; }; export type StrataArgs = Source | "close" | { kind: "pr"; identifier?: string }; export function parseArgs(raw: string): StrataArgs { const args = raw.trim().split(/\s+/u).filter(Boolean); if (args.length === 0) return { kind: "working" }; if (args.length === 1 && args[0] === "close") return "close"; if (args.length === 1 && args[0] === "--staged") return { kind: "staged" }; if (args[0] === "--pr" && args.length <= 2) { const identifier = args[1]; if (identifier?.includes("\0") || (identifier?.length ?? 0) > 2_048) throw new Error("--pr identifier is invalid"); return { kind: "pr", ...(identifier ? { identifier } : {}) }; } if (args.length === 2 && args[0] === "--base") { const ref = args[1]; if (!ref || ref.startsWith("-") || ref.includes("\0")) throw new Error("--base requires a Git revision that is not an option"); if (ref.length > 1_024) throw new Error("--base revision is too long"); return { kind: "base", ref }; } throw new Error( "usage: /strata [--staged | --base REF | --pr [number|URL] | close]", ); } export function isProtectedPath(path: string, nativePath = false): boolean { const normalized = nativePath && process.platform !== "win32" ? path : path.replaceAll("\\", "/"); const lowerParts = normalized.split("/").map((part) => part.toLowerCase()); for (let length = 1; length <= lowerParts.length; length++) { if (isProtectedPrefix(lowerParts.slice(0, length))) return true; } return false; } function isProtectedPrefix(lowerParts: string[]): boolean { const name = lowerParts.at(-1) ?? ""; const joined = lowerParts.join("/"); if (lowerParts.includes(".git")) return true; if ( (name === ".env" || name.startsWith(".env.")) && ![".env.example", ".env.sample", ".env.template"].includes(name) ) return true; if ([".npmrc", ".pypirc", ".netrc"].includes(name)) return true; if (name.endsWith(".p12") || name.endsWith(".pfx")) return true; if (name === "secrets.auto.tfvars") return true; if (name === "secrets.nu" && lowerParts.at(-2) === "nushell") return true; if (lowerParts.includes(".ssh")) return true; if (name.endsWith(".key") || name.endsWith(".pem")) return true; if (lowerParts.includes("private-keys-v1.d") && lowerParts.includes(".gnupg")) return true; if (name === "secring.gpg" && lowerParts.includes(".gnupg")) return true; if ( ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"].some( (prefix) => name === prefix || name.startsWith(`${prefix}.`), ) ) return true; if ( name === "auth.json" && (lowerParts.includes(".pi") || joined.includes("pi/agent/") || lowerParts.includes("containers") || lowerParts.includes(".codex")) ) return true; if (name === ".credentials.json" && lowerParts.includes(".claude")) return true; if ( [ "application_default_credentials.json", "credentials.db", "access_tokens.db", ].includes(name) && lowerParts.includes("gcloud") ) return true; if ( lowerParts.includes("gcloud") && lowerParts.includes("legacy_credentials") ) return true; if (name === "accounts.conf" && lowerParts.includes("aerc")) return true; if (name === "credentials" && lowerParts.includes(".aws")) return true; if ( (name === "credentials" || name === "credentials.toml") && lowerParts.includes(".cargo") ) return true; if (name === ".git-credentials") return true; if (name === "credentials" && joined.includes(".config/git/")) return true; if (name === "config.json" && lowerParts.includes(".docker")) return true; if ( name === "hosts.yml" && (joined.includes(".config/gh/") || joined.includes("gh/")) ) return true; if (name === "config" && lowerParts.includes(".kube")) return true; if (name === "auth.json" && lowerParts.includes("containers")) return true; if (lowerParts.includes(".subversion") && lowerParts.includes("auth")) return true; if ( ["keys.json", "ui-creds.json"].includes(name) && joined.includes("cyborus/forgejo-cli/data/") ) return true; if (name.endsWith(".json") && joined.includes(".pi/google-workspace/tokens/")) return true; if ( name === "config.xml" && (lowerParts.includes("syncthing") || joined.includes(".config/syncthing/")) ) return true; if (name === "picard.ini" && lowerParts.includes("musicbrainz")) return true; return false; } export async function collectSnapshot( cwd: string, source: Source, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const repoRoot = stripFinalNewline( await gitText( cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel"], 16 * 1024, signal, ), ); if (!repoRoot) throw new Error("Git returned an empty repository root"); const headCommit = await resolveCommit(repoRoot, "HEAD", signal); let base = headCommit; let head = headCommit; let revisions: string[]; if (source.kind === "working") { revisions = [headCommit]; } else if (source.kind === "staged") { head = await objectId(repoRoot, ["write-tree"], signal); revisions = [headCommit, head]; } else { if (!source.ref) throw new Error("base source requires a revision"); const requested = await resolveCommit(repoRoot, source.ref, signal); base = await objectId( repoRoot, ["merge-base", requested, headCommit], signal, ); revisions = [base, headCommit]; } const trackedPaths = await trackedManifest(repoRoot, revisions, signal); const protectedPaths = trackedPaths.filter((path) => isProtectedPath(path)); const exclusions = exclusionPathspecs(protectedPaths); if ( exclusions.reduce((sum, pathspec) => sum + pathspec.length + 1, 0) > MAX_PATHSPEC_CHARACTERS ) throw new Error( "Git capture has too many protected path characters for one safe diff invocation", ); const allMetadata = await changedManifest(repoRoot, revisions, [], signal); const allChanges = parseNameStatus(allMetadata); const changes = allChanges.filter( (change) => !isProtectedPath(change.path) && (change.oldPath === undefined || !isProtectedPath(change.oldPath)), ); const metadata = nameStatus(changes); const protectedChanges = allChanges .filter( (change) => isProtectedPath(change.path) || (change.oldPath !== undefined && isProtectedPath(change.oldPath)), ) .map((change) => change.path); let untracked: string[] = []; if (source.kind === "working") untracked = await untrackedManifest(repoRoot, signal); if (changes.length + protectedChanges.length + untracked.length > MAX_CHANGES) throw new Error("Git capture has too many changed and untracked files"); const skipped: Snapshot["skipped"] = protectedChanges.map((path) => ({ path, reason: "protected path", })); for (const path of untracked) skipped.push({ path, reason: isProtectedPath(path) ? "protected path" : "untracked", }); const patch = changes.length === 0 ? "" : await gitText( repoRoot, diffArgs(revisions, ["--patch"], exclusions), MAX_PATCH_BYTES, signal, ); if (source.kind === "working") { const afterChanges = parseNameStatus( await changedManifest(repoRoot, revisions, [], signal), ).filter( (change) => !isProtectedPath(change.path) && (change.oldPath === undefined || !isProtectedPath(change.oldPath)), ); const afterUntracked = await untrackedManifest(repoRoot, signal); if ( nameStatus(afterChanges) !== metadata || !samePaths(afterUntracked, untracked) ) throw new Error("Git working tree changed during snapshot capture"); } const parsed = parsePatch(patch, changes); for (const [index, block] of parsed.entries()) { if (block.binary) skipped.push({ path: (changes[index] as Change).path, reason: "binary", }); } const hunks = parsed .flatMap((block) => block.hunks) .map((hunk) => ({ ...hunk, id: hunkId(hunk) })); const ids = new Set(hunks.map((hunk) => hunk.id)); if (ids.size !== hunks.length) throw new Error("Git capture produced duplicate hunk IDs"); skipped.sort((left, right) => left.path === right.path ? left.reason.localeCompare(right.reason) : left.path.localeCompare(right.path), ); const id = sha256(JSON.stringify({ source, base, head, patch, skipped })); return { id, repoRoot, source, base, head, hunks, skipped }; } function diffArgs( revisions: string[], options: string[], exclusions: string[] = [], inclusions: string[] = ["."], ): string[] { return [ "-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--find-renames=50%", "--submodule=short", "--unified=3", "--src-prefix=a/", "--dst-prefix=b/", ...revisions, ...options, "--", ...inclusions, ...exclusions, ]; } async function trackedManifest( cwd: string, revisions: string[], signal?: AbortSignal, ): Promise { const manifests = await Promise.allSettled([ gitText(cwd, ["ls-files", "-z"], MAX_METADATA_BYTES, signal), ...revisions.map((revision) => gitText( cwd, ["ls-tree", "-r", "--name-only", "-z", revision], MAX_METADATA_BYTES, signal, ), ), ]); return unique( manifests.flatMap((result) => { if (result.status === "rejected") throw result.reason; return parseNulPaths(result.value); }), ); } function changedManifest( cwd: string, revisions: string[], exclusions: string[], signal?: AbortSignal, ): Promise { return gitText( cwd, diffArgs(revisions, ["--name-status", "-z"], exclusions), MAX_METADATA_BYTES, signal, ); } async function untrackedManifest( cwd: string, signal?: AbortSignal, ): Promise { return parseNulPaths( await gitText( cwd, ["ls-files", "--others", "--exclude-standard", "-z"], MAX_METADATA_BYTES, signal, ), ); } function exclusionPathspecs(paths: string[]): string[] { return paths.map((path) => `:(exclude,literal)${path}`); } function nameStatus(changes: Change[]): string { return changes .flatMap((change) => change.oldPath === undefined ? [change.status, change.path] : [change.status, change.oldPath, change.path], ) .join("\0") .concat(changes.length === 0 ? "" : "\0"); } function parseNameStatus(text: string): Change[] { const fields = nulFields(text); const changes: Change[] = []; for (let index = 0; index < fields.length; ) { const status = fields[index++]; if (!status || !/^[ACDMRTUXB][0-9]*$/u.test(status)) throw new Error("malformed Git name-status capture"); if (status.startsWith("R") || status.startsWith("C")) { const oldPath = fields[index++]; const path = fields[index++]; if (!oldPath || !path) throw new Error("malformed Git rename/copy capture"); changes.push({ status, oldPath, path }); } else { const path = fields[index++]; if (!path) throw new Error("malformed Git path capture"); changes.push({ status, path }); } } return changes; } function parseNulPaths(text: string): string[] { const paths = nulFields(text); if (paths.some((path) => path.length === 0)) throw new Error("malformed Git untracked path capture"); return paths; } function nulFields(text: string): string[] { if (!text) return []; if (!text.endsWith("\0")) throw new Error("malformed unterminated Git metadata capture"); return text.slice(0, -1).split("\0"); } function parsePatch(patch: string, changes: Change[]): ParsedBlock[] { if (changes.length === 0) { if (patch !== "") throw new Error("Git produced a patch without metadata"); return []; } if (!patch) throw new Error("Git metadata and patch capture disagree"); const lines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n"); const blocks: string[][] = []; for (const line of lines) { if (line.startsWith("diff --git ")) blocks.push([line]); else { const block = blocks.at(-1); if (!block) throw new Error("malformed Git patch before first file header"); block.push(line); } } if (blocks.length !== changes.length) throw new Error( `Git metadata and patch disagree (${changes.length} files, ${blocks.length} patches)`, ); return blocks.map((block, index) => parseBlock(block, changes[index] as Change), ); } function parseBlock(lines: string[], change: Change): ParsedBlock { if (!lines[0]?.startsWith("diff --git ")) throw new Error("malformed Git file header"); if (lines[0] !== diffHeader(change)) throw new Error( `Git patch header does not match metadata for ${change.path}`, ); const hunks: Array> = []; const metadata: string[] = []; let binary = false; let index = 1; while (index < lines.length) { const line = lines[index] as string; if (line.startsWith("@@ ")) { const parsed = parseHunk(lines, index, change); hunks.push(parsed.hunk); index = parsed.next; continue; } if (line.startsWith("Binary files ")) binary = true; if (!isMetadataLine(line)) throw new Error(`malformed Git patch metadata for ${change.path}`); metadata.push(line); index++; } validateStatus(change, metadata); const meaningful = metadata.filter(isMeaningfulMetadata); if (hunks.length === 0) { if (meaningful.length === 0) throw new Error( `Git patch for ${change.path} contains no hunk or metadata change`, ); hunks.push(metadataHunk(change, meaningful)); } else if (meaningful.length > 0) { hunks[0]?.lines.unshift( ...meaningful.map((text) => ({ kind: "meta" as const, text })), ); } return { hunks, binary }; } function metadataHunk(change: Change, metadata: string[]): Omit { return { path: change.path, ...(change.oldPath && change.oldPath !== change.path ? { oldPath: change.oldPath } : {}), header: "metadata", lines: metadata.map((text) => ({ kind: "meta" as const, text })), }; } function diffHeader(change: Change): string { return `diff --git ${gitQuotePath("a", change.oldPath ?? change.path)} ${gitQuotePath("b", change.path)}`; } function gitQuotePath(prefix: string, path: string): string { const value = `${prefix}/${path}`; let quoted = false; let escaped = ""; for (const character of value) { switch (character) { case "\\": escaped += "\\\\"; quoted = true; break; case '"': escaped += '\\"'; quoted = true; break; case "\t": escaped += "\\t"; quoted = true; break; case "\n": escaped += "\\n"; quoted = true; break; case "\r": escaped += "\\r"; quoted = true; break; default: if (character.charCodeAt(0) < 0x20) quoted = true; escaped += character; } } return quoted ? `"${escaped}"` : escaped; } function parseHunk( lines: string[], start: number, change: Change, ): { hunk: Omit; next: number } { const header = lines[start] as string; const match = HUNK_HEADER.exec(header); if (!match) throw new Error(`malformed Git hunk header for ${change.path}`); let oldLine = Number(match[1]); let newLine = Number(match[3]); const expectedOld = match[2] === undefined ? 1 : Number(match[2]); const expectedNew = match[4] === undefined ? 1 : Number(match[4]); let consumedOld = 0; let consumedNew = 0; const hunkLines: DiffLine[] = []; let index = start + 1; for (; index < lines.length; index++) { const line = lines[index] as string; if (line.startsWith("@@ ")) break; if (line.startsWith("diff --git ")) break; if (line.startsWith("\\")) { hunkLines.push({ kind: "meta", text: line }); continue; } const marker = line[0]; const text = line.slice(1); if (marker === " ") { hunkLines.push({ kind: "context", text, oldLine, newLine }); oldLine++; newLine++; consumedOld++; consumedNew++; } else if (marker === "+") { hunkLines.push({ kind: "add", text, newLine }); newLine++; consumedNew++; } else if (marker === "-") { hunkLines.push({ kind: "delete", text, oldLine }); oldLine++; consumedOld++; } else { throw new Error(`malformed Git hunk line for ${change.path}`); } } if (consumedOld !== expectedOld || consumedNew !== expectedNew) throw new Error( `Git hunk counts disagree for ${change.path}: expected ${expectedOld}/${expectedNew}, got ${consumedOld}/${consumedNew}`, ); return { hunk: { path: change.path, ...(change.oldPath && change.oldPath !== change.path ? { oldPath: change.oldPath } : {}), header, lines: hunkLines, }, next: index, }; } function isMetadataLine(line: string): boolean { return [ "index ", "old mode ", "new mode ", "new file mode ", "deleted file mode ", "similarity index ", "dissimilarity index ", "rename from ", "rename to ", "copy from ", "copy to ", "--- ", "+++ ", "Binary files ", ].some((prefix) => line.startsWith(prefix)); } function isMeaningfulMetadata(line: string): boolean { return ( !line.startsWith("index ") && !line.startsWith("--- ") && !line.startsWith("+++ ") ); } function validateStatus(change: Change, metadata: string[]): void { const kind = change.status[0]; if ( kind === "R" && (!metadata.some((line) => line.startsWith("rename from ")) || !metadata.some((line) => line.startsWith("rename to "))) ) throw new Error(`Git rename metadata is incomplete for ${change.path}`); if ( kind === "C" && (!metadata.some((line) => line.startsWith("copy from ")) || !metadata.some((line) => line.startsWith("copy to "))) ) throw new Error(`Git copy metadata is incomplete for ${change.path}`); if ( kind === "A" && !metadata.some( (line) => line.startsWith("new file mode ") || line === "--- /dev/null", ) ) throw new Error(`Git addition metadata is incomplete for ${change.path}`); if ( kind === "D" && !metadata.some( (line) => line.startsWith("deleted file mode ") || line === "+++ /dev/null", ) ) throw new Error(`Git deletion metadata is incomplete for ${change.path}`); } async function resolveCommit( cwd: string, ref: string, signal?: AbortSignal, ): Promise { return objectId( cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, ); } async function objectId( cwd: string, args: string[], signal?: AbortSignal, ): Promise { const id = stripFinalNewline(await gitText(cwd, args, 4 * 1024, signal)); if (!/^[0-9a-f]{40,64}$/u.test(id)) throw new Error("Git returned an invalid object ID"); return id; } export async function gitText( cwd: string, args: string[], maxBytes: number, signal?: AbortSignal, ): Promise { const buffer = await runGit(cwd, args, maxBytes, signal); try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { throw new Error("Git output is not valid UTF-8"); } } export async function gitSucceeds( cwd: string, args: string[], signal?: AbortSignal, ): Promise { try { await runGit(cwd, args, 4 * 1024, signal); return true; } catch (error) { if (signal?.aborted) signal.throwIfAborted(); if (error instanceof Error && error.message.includes("ENOENT")) throw error; return false; } } async function runGit( cwd: string, args: string[], maxBytes: number, signal?: AbortSignal, ): Promise { const command = await gitCommand(cwd, signal); return new Promise((resolve, reject) => { signal?.throwIfAborted(); const child = spawn(command, ["-c", "core.fsmonitor=false", ...args], { cwd, env: cleanGitEnvironment(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; let stdoutBytes = 0; let stderrBytes = 0; let failure: Error | undefined; const finish = (error?: Error, output?: Buffer) => { signal?.removeEventListener("abort", abort); if (error) reject(error); else resolve(output ?? Buffer.alloc(0)); }; const abort = () => { failure ??= signal?.reason instanceof Error ? signal.reason : new Error("Git capture aborted"); child.kill(); }; signal?.addEventListener("abort", abort, { once: true }); child.stdout.on("data", (chunk: Buffer) => { stdoutBytes += chunk.length; if (stdoutBytes > maxBytes) { failure ??= new Error(`Git output exceeds ${maxBytes} byte limit`); child.kill(); return; } stdout.push(chunk); }); child.stderr.on("data", (chunk: Buffer) => { if (stderrBytes >= MAX_STDERR_BYTES) return; const remaining = MAX_STDERR_BYTES - stderrBytes; stderr.push(chunk.subarray(0, remaining)); stderrBytes += Math.min(chunk.length, remaining); }); child.once("error", (error) => { failure ??= error; }); child.once("close", (code) => { if (failure) return finish(failure); if (code === 0) finish(undefined, Buffer.concat(stdout)); else { const detail = Buffer.concat(stderr).toString("utf8").trim(); finish( new Error(detail || `git exited with code ${code ?? "unknown"}`), ); } }); }); } async function gitCommand(cwd: string, signal?: AbortSignal): Promise { if (process.platform !== "win32") return "git"; const paths = Object.entries(process.env).filter( ([key]) => key.toLowerCase() === "path", ); // cleanGitEnvironment retains PATH, but duplicate case variants are ambiguous. if (paths.length !== 1) return "git"; const path = paths[0]?.[1]; const launcher = await selectedWindowsGit(cwd, path); if (!launcher) return "git"; const key = `${process.platform}\0${cwd}\0${path ?? ""}\0${launcher}`; if (cachedGitCommand?.key === key) return cachedGitCommand.command; const command = await gitForWindowsBinary(launcher, signal); if (command !== "git") cachedGitCommand = { key, command }; return command; } async function selectedWindowsGit( cwd: string, path: string | undefined, ): Promise { if (!path) return undefined; const directories = path .split(";") .map((entry) => entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry, ); // Relative or empty entries make CreateProcess lookup cwd-dependent. if (directories.some((directory) => !directory || !isAbsolute(directory))) return undefined; if (await isWindowsFile(join(cwd, "git.com"))) return undefined; if (await isWindowsFile(join(cwd, "git.exe"))) return undefined; for (const directory of directories) { if (await isWindowsFile(join(directory, "git.com"))) return undefined; const candidate = join(directory, "git.exe"); if (await isWindowsFile(candidate)) return candidate; } return undefined; } async function isWindowsFile(path: string): Promise { try { return (await stat(path)).isFile(); } catch { return false; } } export async function gitForWindowsBinary( launcher: string, signal?: AbortSignal, ): Promise { if ( basename(launcher).toLowerCase() !== "git.exe" || basename(dirname(launcher)).toLowerCase() !== "cmd" ) return "git"; const installation = dirname(dirname(launcher)); const launcherVersion = await gitIdentity(launcher, ["version"], signal); if (!launcherVersion) return "git"; for (const runtime of ["ucrt64", "mingw64"]) { const candidate = join(installation, runtime, "bin", "git.exe"); if (!(await isWindowsFile(candidate))) continue; const version = await gitIdentity(candidate, ["version"], signal); const execPath = await gitIdentity( candidate, ["-c", "core.fsmonitor=false", "--exec-path"], signal, ); if ( version === launcherVersion && execPath !== undefined && isWithinInstallation(execPath, join(installation, runtime)) ) return candidate; } return "git"; } function isWithinInstallation(path: string, root: string): boolean { const normalizedPath = path.replaceAll("/", "\\").toLowerCase(); const normalizedRoot = root.replaceAll("/", "\\").toLowerCase(); return ( normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}\\`) ); } async function gitIdentity( command: string, args: string[], signal?: AbortSignal, ): Promise { try { return stripFinalNewline( await runGitCommand(command, args, MAX_GIT_IDENTITY_BYTES, signal), ); } catch { if (signal?.aborted) signal.throwIfAborted(); return undefined; } } function runGitCommand( command: string, args: string[], maxBytes: number, signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { signal?.throwIfAborted(); const child = spawn(command, args, { env: cleanGitEnvironment(), stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }); const stdout: Buffer[] = []; let bytes = 0; let failure: Error | undefined; const timeout = setTimeout(() => { failure ??= new Error("Git identity probe timed out"); child.kill(); }, GIT_IDENTITY_TIMEOUT_MS); const finish = (error?: Error) => { clearTimeout(timeout); signal?.removeEventListener("abort", abort); if (error) reject(error); else resolve(Buffer.concat(stdout).toString("utf8")); }; const abort = () => { failure ??= signal?.reason instanceof Error ? signal.reason : new Error("Git capture aborted"); child.kill(); }; signal?.addEventListener("abort", abort, { once: true }); child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) { failure ??= new Error("Git identity output exceeds byte limit"); child.kill(); return; } stdout.push(chunk); }); child.once("error", (error) => { failure ??= error; }); child.once("close", (code) => { if (failure) return finish(failure); if (code === 0) finish(); else finish(new Error(`git exited with code ${code ?? "unknown"}`)); }); }); } export function cleanGitEnvironment(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (!key.toUpperCase().startsWith("GIT_") && value !== undefined) env[key] = value; } env["GIT_CONFIG_NOSYSTEM"] = "1"; // Git for Windows recognizes /dev/null; native NUL paths are rejected. env["GIT_CONFIG_GLOBAL"] = "/dev/null"; env["GIT_TERMINAL_PROMPT"] = "0"; return env; } function stripFinalNewline(value: string): string { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; } function unique(values: string[]): string[] { return [...new Set(values)]; } function samePaths(left: string[], right: string[]): boolean { return ( left.length === right.length && left.every((path, index) => path === right[index]) ); } function hunkId(hunk: Omit): string { return `h-${sha256(JSON.stringify(hunk)).slice(0, 20)}`; } function sha256(value: string): string { return createHash("sha256").update(value, "utf8").digest("hex"); }