Skip to content

Commit 3bbe667

Browse files
authored
Merge pull request #9119 from Kilo-Org/fix/snapshot-diff-freeze
fix(cli): fix hanging sessions on huge-file diffs
2 parents 740c2b4 + ce50f5c commit 3bbe667

6 files changed

Lines changed: 517 additions & 0 deletions

File tree

.changeset/snapshot-diff-freeze.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": patch
3+
---
4+
5+
Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session.

packages/opencode/src/file/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Git } from "@/git"
66
import { Effect, Layer, Context } from "effect"
77
import * as Stream from "effect/Stream"
88
import { formatPatch, structuredPatch } from "diff"
9+
import { DiffFull } from "@/kilocode/snapshot/diff-full" // kilocode_change
910
import fuzzysort from "fuzzysort"
1011
import ignore from "ignore"
1112
import path from "path"
@@ -559,6 +560,13 @@ export namespace File {
559560
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
560561
}
561562
if (diff.trim()) {
563+
// kilocode_change start — patch via git (DiffFull.file) instead of the JS Myers
564+
// implementation. Upstream structuredPatch branch below is kept as dead code so
565+
// our diff from upstream stays minimal and future merges don't conflict.
566+
const got = yield* DiffFull.file(gitText, file)
567+
if (got) return { type: "text" as const, content, patch: got.patch, diff: got.text }
568+
return { type: "text" as const, content }
569+
// kilocode_change end
562570
const original = yield* git.show(Instance.directory, "HEAD", file)
563571
const patch = structuredPatch(file, file, original, content, "old", "new", {
564572
context: Infinity,
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
}

packages/opencode/src/snapshot/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Config } from "../config/config"
1212
import { Global } from "../global"
1313
import { Hash } from "../util/hash"
1414
import { Log } from "../util/log"
15+
import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change
1516

1617
export namespace Snapshot {
1718
export const Patch = z.object({
@@ -718,6 +719,30 @@ export namespace Snapshot {
718719
const patch = (file: string, before: string, after: string) =>
719720
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
720721

722+
// kilocode_change start — route patches through git (DiffFull.batch) instead of the
723+
// JS Myers implementation. Upstream Myers loop below is kept as dead code so our
724+
// diff from upstream stays minimal and future merges don't conflict.
725+
for (let i = 0; i < rows.length; i += step) {
726+
const run = rows.slice(i, i + step)
727+
const patches = yield* DiffFull.batch(
728+
(cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }),
729+
from,
730+
to,
731+
run.filter((r) => !r.binary).map((r) => r.file),
732+
)
733+
for (const row of run) {
734+
result.push({
735+
file: row.file,
736+
patch: row.binary ? "" : (patches.get(row.file) ?? ""),
737+
additions: row.additions,
738+
deletions: row.deletions,
739+
status: row.status,
740+
})
741+
}
742+
}
743+
return result
744+
// kilocode_change end
745+
721746
for (let i = 0; i < rows.length; i += step) {
722747
const run = rows.slice(i, i + step)
723748
const text = yield* load(run)

0 commit comments

Comments
 (0)