|
| 1 | +// kilocode_change - new file |
| 2 | +// |
| 3 | +// Patch generation. Runs `git diff --unified=INT_MAX` to produce |
| 4 | +// unified-diff text for a set of files, instead of the npm `diff` package's |
| 5 | +// JS Myers implementation. Myers is O(N*M) with full context, so on |
| 6 | +// huge-file diffs it can block the event loop for minutes (the TUI freeze |
| 7 | +// where ESC stopped working after a turn). |
| 8 | +// |
| 9 | +// Both helpers fail soft: on any git error they return an empty value so |
| 10 | +// callers emit an empty patch string. Additions/deletions come from |
| 11 | +// `git --numstat` and stay accurate. |
| 12 | + |
| 13 | +import { Effect } from "effect" |
| 14 | +import { parsePatch } from "diff" |
| 15 | +import type { StructuredPatch } from "diff" |
| 16 | +import { Log } from "../../util/log" |
| 17 | + |
| 18 | +export namespace DiffFull { |
| 19 | + const log = Log.create({ service: "snapshot.diff-full" }) |
| 20 | + |
| 21 | + // INT_MAX — git clamps to this, effectively infinite context. |
| 22 | + const unified = "--unified=2147483647" |
| 23 | + |
| 24 | + interface GitResult { |
| 25 | + readonly code: number |
| 26 | + readonly text: string |
| 27 | + readonly stderr: string |
| 28 | + } |
| 29 | + |
| 30 | + /** |
| 31 | + * Run `git diff --unified=INT_MAX` for a set of files between two refs and |
| 32 | + * return a `file → unified-diff text` map. Output format matches what the |
| 33 | + * `diff` package's `parsePatch` expects, so downstream clients continue to |
| 34 | + * work. |
| 35 | + * |
| 36 | + * `files` entries must use forward slashes (git's output uses `/` even on |
| 37 | + * Windows); paths with backslashes will silently miss the suffix match. |
| 38 | + * |
| 39 | + * Returns an empty map if `files` is empty or git fails. Callers emit an |
| 40 | + * empty patch string for any file missing from the map; numstat-derived |
| 41 | + * additions/deletions stay accurate. |
| 42 | + */ |
| 43 | + export const batch = Effect.fn("DiffFull.batch")(function* ( |
| 44 | + git: (cmd: string[]) => Effect.Effect<GitResult>, |
| 45 | + from: string, |
| 46 | + to: string, |
| 47 | + files: string[], |
| 48 | + ) { |
| 49 | + const map = new Map<string, string>() |
| 50 | + if (files.length === 0) return map |
| 51 | + |
| 52 | + // Windows cmdline limit is ~8191 chars. 500 * avg-15-char filename ≈ 7500. |
| 53 | + const size = 500 |
| 54 | + let failed = 0 |
| 55 | + let stderr = "" |
| 56 | + for (let i = 0; i < files.length; i += size) { |
| 57 | + const chunk = files.slice(i, i + size) |
| 58 | + const result = yield* git([ |
| 59 | + "diff", |
| 60 | + "--no-color", |
| 61 | + "--no-ext-diff", |
| 62 | + "--no-renames", |
| 63 | + unified, |
| 64 | + from, |
| 65 | + to, |
| 66 | + "--", |
| 67 | + ...chunk, |
| 68 | + ]) |
| 69 | + if (result.code !== 0) { |
| 70 | + failed += 1 |
| 71 | + stderr = result.stderr || stderr |
| 72 | + continue |
| 73 | + } |
| 74 | + parseBatch(result.text, chunk, map) |
| 75 | + } |
| 76 | + if (failed) { |
| 77 | + log.info("git diff failed, emitting empty patches for affected files", { |
| 78 | + chunksFailed: failed, |
| 79 | + filesTotal: files.length, |
| 80 | + stderr, |
| 81 | + }) |
| 82 | + } |
| 83 | + return map |
| 84 | + }) |
| 85 | + |
| 86 | + /** |
| 87 | + * Generate a structured + unified diff for a single file in the working |
| 88 | + * tree vs HEAD using `git diff --ignore-all-space --unified=INT_MAX`. |
| 89 | + * Returns `null` if git produces no output (caller emits a content-only |
| 90 | + * response with no patch). |
| 91 | + */ |
| 92 | + export const file = Effect.fn("DiffFull.file")(function* ( |
| 93 | + gitText: (args: string[]) => Effect.Effect<string>, |
| 94 | + file: string, |
| 95 | + ) { |
| 96 | + const flags = ["-c", "core.fsmonitor=false", "diff", "--no-color", "--no-ext-diff", "--ignore-all-space", unified] |
| 97 | + const primary = yield* gitText([...flags, "--", file]) |
| 98 | + const text = primary.trim() ? primary : yield* gitText([...flags, "--staged", "--", file]) |
| 99 | + if (!text.trim()) return null |
| 100 | + const parsed = parsePatch(text)[0] |
| 101 | + if (!parsed) return null |
| 102 | + // Normalize paths to match what `structuredPatch(file, file, ...)` used to |
| 103 | + // produce — downstream UIs key off the bare filename, not `a/…` / `b/…`. |
| 104 | + const patch: StructuredPatch = { |
| 105 | + ...parsed, |
| 106 | + oldFileName: file, |
| 107 | + newFileName: file, |
| 108 | + } |
| 109 | + return { patch, text } |
| 110 | + }) |
| 111 | + |
| 112 | + /** |
| 113 | + * Split a multi-file `git diff` output into one entry per file, keyed by |
| 114 | + * the input filename (not the path from the header — that can be quoted). |
| 115 | + * Silently drops sections whose header does not match any entry in `chunk`. |
| 116 | + */ |
| 117 | + function parseBatch(text: string, chunk: string[], map: Map<string, string>) { |
| 118 | + // Longest-first so `lib/a.txt` beats `a.txt` on suffix matches. |
| 119 | + const ordered = chunk.slice().sort((a, b) => b.length - a.length) |
| 120 | + // With `--no-renames` the header is always `diff --git a/PATH b/PATH` with |
| 121 | + // both PATHs identical, so we can confirm both halves to avoid false |
| 122 | + // positives where PATH happens to also appear as a substring earlier in |
| 123 | + // the line (e.g. a filename containing ` b/`). |
| 124 | + const match = (header: string) => { |
| 125 | + for (const f of ordered) { |
| 126 | + if (header.endsWith(" b/" + f) && header.includes(" a/" + f + " ")) return f |
| 127 | + if (header.endsWith(` "b/${f}"`) && header.includes(` "a/${f}" `)) return f |
| 128 | + } |
| 129 | + return null |
| 130 | + } |
| 131 | + |
| 132 | + let current: string | null = null |
| 133 | + let buffer: string[] = [] |
| 134 | + const flush = () => { |
| 135 | + if (current !== null && buffer.length) map.set(current, buffer.join("\n")) |
| 136 | + current = null |
| 137 | + buffer = [] |
| 138 | + } |
| 139 | + |
| 140 | + for (const line of text.split("\n")) { |
| 141 | + if (line.startsWith("diff --git ")) { |
| 142 | + flush() |
| 143 | + current = match(line) |
| 144 | + if (current !== null) buffer.push(line) |
| 145 | + continue |
| 146 | + } |
| 147 | + if (current !== null) buffer.push(line) |
| 148 | + } |
| 149 | + flush() |
| 150 | + } |
| 151 | +} |
0 commit comments