import { structuredPatch } from "diff"; import { env, waitUntil } from "cloudflare:workers"; import type { GitStore } from "./store"; import type { DiffResult, FileChange } from "~/lib/types"; const MAX_CONTENT_FILES = 300; const MAX_TREE_READS = 2000; const MAX_CHANGED_FILES = 5000; const MAX_EDIT_LENGTH = 20_000; const DIFF_TIMEOUT_MS = 1500; const MAX_BLOB_BYTES = 512 * 1024; const BINARY_SNIFF_BYTES = 8000; const BLOB_CONCURRENCY = 8; const TREE_CONCURRENCY = 8; const QUOTED_PATH_CHARS = /["\\\x00-\x1f\x7f]/; const C_ESCAPES: Record = { "\n": "\\n", "\t": "\\t", '"': '\\"', "\\": "\\\\" }; const CONTEXT_LINES = 3; const MAX_CACHED_PATCH_BYTES = 4 * 1024 * 1024; const DIFF_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; const DIFF_CACHE_VERSION = "v2"; const decoder = new TextDecoder(); /** Quotes a path for patch text the way git does, so names with newlines cannot forge header or hunk lines. */ function patchPath(prefix: string, path: string): string { const full = `${prefix}${path}`; if (!QUOTED_PATH_CHARS.test(full)) return full; const escaped = full.replace(/["\\\x00-\x1f\x7f]/g, (char) => { return C_ESCAPES[char] ?? `\\${char.charCodeAt(0).toString(8).padStart(3, "0")}`; }); return `"${escaped}"`; } type Entry = { name: string; mode: string; hash: string; type: ArtifactsTreeEntryType }; function joinPath(parent: string, name: string): string { return parent ? `${parent}/${name}` : name; } async function entriesOf(store: GitStore, sha: string | null): Promise> { if (!sha) return new Map(); const entries = (await store.tree(sha)) ?? []; return new Map(entries.map((entry) => [entry.name, entry])); } type Budget = { treeReads: number }; function exhausted(budget: Budget, out: FileChange[]): boolean { return budget.treeReads >= MAX_TREE_READS || out.length >= MAX_CHANGED_FILES; } async function collectAll( store: GitStore, entry: Entry, path: string, side: "old" | "new", out: FileChange[], budget: Budget, ) { if (exhausted(budget, out)) return; if (entry.type !== "tree") { out.push({ path, oldPath: null, status: side === "old" ? "deleted" : "added", oldSha: side === "old" ? entry.hash : null, newSha: side === "new" ? entry.hash : null, oldMode: side === "old" ? entry.mode : null, newMode: side === "new" ? entry.mode : null, }); return; } budget.treeReads++; const children = await entriesOf(store, entry.hash); for (const [name, child] of children) { await collectAll(store, child, joinPath(path, name), side, out, budget); } } /** Computes changed files between two root trees, reporting whether the tree-read or file budget cut the result short. */ export async function diffTreesBounded( store: GitStore, oldTree: string | null, newTree: string | null, ): Promise<{ changes: FileChange[]; truncated: boolean }> { const out: FileChange[] = []; const budget: Budget = { treeReads: 0 }; async function walk(oldSha: string | null, newSha: string | null, base: string) { if (oldSha === newSha || exhausted(budget, out)) return; budget.treeReads += 2; const [oldEntries, newEntries] = await Promise.all([entriesOf(store, oldSha), entriesOf(store, newSha)]); const names = [...new Set([...oldEntries.keys(), ...newEntries.keys()])].sort(); const subtrees: Array<() => Promise> = []; for (const name of names) { const before = oldEntries.get(name); const after = newEntries.get(name); const path = joinPath(base, name); if (before && after && before.hash === after.hash && before.mode === after.mode) continue; if (before?.type === "tree" && after?.type === "tree") { subtrees.push(() => walk(before.hash, after.hash, path)); continue; } if (before && after && before.type !== "tree" && after.type !== "tree") { out.push({ path, oldPath: null, status: before.type === after.type ? "modified" : "type_changed", oldSha: before.hash, newSha: after.hash, oldMode: before.mode, newMode: after.mode, }); continue; } if (before) subtrees.push(() => collectAll(store, before, path, "old", out, budget)); if (after) subtrees.push(() => collectAll(store, after, path, "new", out, budget)); } await mapLimit(subtrees, TREE_CONCURRENCY, (task) => task()); } await walk(oldTree, newTree, ""); return { changes: detectRenames(out).sort((left, right) => left.path.localeCompare(right.path)), truncated: exhausted(budget, out), }; } function detectRenames(changes: FileChange[]): FileChange[] { const deleted = new Map(); for (const change of changes) { if (change.status === "deleted" && change.oldSha) deleted.set(change.oldSha, change); } const consumed = new Set(); const out: FileChange[] = []; for (const change of changes) { if (change.status !== "added" || !change.newSha) continue; const source = deleted.get(change.newSha); if (source && !consumed.has(source)) { consumed.add(source); out.push({ ...change, status: "renamed", oldPath: source.path, oldSha: source.oldSha, oldMode: source.oldMode }); } } const renamedTargets = new Set(out.map((change) => change.path)); for (const change of changes) { if (consumed.has(change) || renamedTargets.has(change.path)) continue; out.push(change); } return out; } function isBinary(bytes: Uint8Array): boolean { const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES); return sniff.includes(0); } type BlobText = { text: string; binary: boolean; tooLarge: boolean }; async function readText(store: GitStore, sha: string | null): Promise { if (!sha) return { text: "", binary: false, tooLarge: false }; const bytes = await store.blob(sha); if (!bytes) return { text: "", binary: false, tooLarge: false }; if (bytes.byteLength > MAX_BLOB_BYTES) return { text: "", binary: false, tooLarge: true }; if (isBinary(bytes)) return { text: "", binary: true, tooLarge: false }; return { text: decoder.decode(bytes), binary: false, tooLarge: false }; } async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { const results: R[] = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { const index = next++; results[index] = await fn(items[index]); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return results; } function shortHash(sha: string | null): string { return (sha ?? "0000000").slice(0, 7); } function fileHeader(change: FileChange): string[] { const oldPath = change.oldPath ?? change.path; const lines = [`diff --git ${patchPath("a/", oldPath)} ${patchPath("b/", change.path)}`]; if (change.status === "added") lines.push(`new file mode ${change.newMode}`); if (change.status === "deleted") lines.push(`deleted file mode ${change.oldMode}`); if (change.status === "renamed") lines.push( "similarity index 100%", `rename from ${patchPath("", oldPath)}`, `rename to ${patchPath("", change.path)}`, ); if (change.oldMode && change.newMode && change.oldMode !== change.newMode && change.status !== "renamed") { lines.push(`old mode ${change.oldMode}`, `new mode ${change.newMode}`); } if (change.status !== "renamed") { const modeSuffix = change.oldMode === change.newMode && change.newMode ? ` ${change.newMode}` : ""; lines.push(`index ${shortHash(change.oldSha)}..${shortHash(change.newSha)}${modeSuffix}`); } return lines; } async function renderFile(store: GitStore, change: FileChange, includeContent: boolean) { const header = fileHeader(change); const base = { ...change, additions: 0, deletions: 0, binary: false, truncated: !includeContent }; if (!includeContent || change.status === "renamed") { return { text: header.join("\n"), file: { ...base, truncated: change.status !== "renamed" && !includeContent } }; } const [before, after] = await Promise.all([readText(store, change.oldSha), readText(store, change.newSha)]); if (before.binary || after.binary) { const oldName = change.status === "added" ? "/dev/null" : patchPath("a/", change.oldPath ?? change.path); const newName = change.status === "deleted" ? "/dev/null" : patchPath("b/", change.path); return { text: [...header, `Binary files ${oldName} and ${newName} differ`].join("\n"), file: { ...base, binary: true }, }; } if (before.tooLarge || after.tooLarge) { return { text: header.join("\n"), file: { ...base, truncated: true } }; } const patch = structuredPatch("", "", before.text, after.text, undefined, undefined, { context: CONTEXT_LINES, maxEditLength: MAX_EDIT_LENGTH, timeout: DIFF_TIMEOUT_MS, }); if (!patch) return { text: header.join("\n"), file: { ...base, truncated: true } }; if (patch.hunks.length === 0) return { text: header.join("\n"), file: base }; const lines = [ ...header, change.status === "added" ? "--- /dev/null" : `--- ${patchPath("a/", change.oldPath ?? change.path)}`, change.status === "deleted" ? "+++ /dev/null" : `+++ ${patchPath("b/", change.path)}`, ]; let additions = 0; let deletions = 0; for (const hunk of patch.hunks) { lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); for (const line of hunk.lines) { if (line.startsWith("+")) additions++; else if (line.startsWith("-")) deletions++; lines.push(line); } } return { text: lines.join("\n"), file: { ...base, additions, deletions } }; } export async function renderDiff(store: GitStore, changes: FileChange[], partial = false): Promise { const indexed = changes.map((change, index) => ({ change, index })); const rendered = await mapLimit(indexed, BLOB_CONCURRENCY, ({ change, index }) => renderFile(store, change, index < MAX_CONTENT_FILES), ); const files = rendered.map((item) => item.file); return { patch: rendered.map((item) => item.text).join("\n") + (rendered.length > 0 ? "\n" : ""), files, additions: files.reduce((sum, file) => sum + file.additions, 0), deletions: files.reduce((sum, file) => sum + file.deletions, 0), truncated: partial || changes.length > MAX_CONTENT_FILES, }; } /** Full diff between two root trees, cached in KV since tree pairs are immutable. */ export async function diffTreesCached(store: GitStore, oldTree: string | null, newTree: string): Promise { const key = `diff:${DIFF_CACHE_VERSION}:${store.artifactsName}:${oldTree ?? "empty"}:${newTree}`; const cached = await env.KV.get(key, "json"); if (cached) return cached; const bounded = await diffTreesBounded(store, oldTree, newTree); const result = await renderDiff(store, bounded.changes, bounded.truncated); if (result.patch.length < MAX_CACHED_PATCH_BYTES) { waitUntil(env.KV.put(key, JSON.stringify(result), { expirationTtl: DIFF_CACHE_TTL_SECONDS })); } return result; }