diff --git a/.changeset/safe-windows-snapshot-diffs.md b/.changeset/safe-windows-snapshot-diffs.md
new file mode 100644
index 00000000000..55dd9e61bc6
--- /dev/null
+++ b/.changeset/safe-windows-snapshot-diffs.md
@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": patch
+"kilo-code": patch
+---
+
+Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts
index 58fc6d8bdd4..924c232659d 100644
--- a/packages/kilo-vscode/src/diff/sources/session.ts
+++ b/packages/kilo-vscode/src/diff/sources/session.ts
@@ -83,7 +83,15 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile {
// Empty patch means binary or summarized (>256 KB) — normalize() can't
// parse it, so short-circuit to empty strings. Binary snapshot images do
// not retain their sides, while text-backed SVG patches can be rebuilt.
- const view = raw.patch === "" || (mime && mime !== "image/svg+xml") ? null : normalize(raw)
+ const view = (() => {
+ if (raw.patch === "" || (mime && mime !== "image/svg+xml")) return null
+ try {
+ return normalize(raw)
+ } catch (err) {
+ console.warn("[Kilo New] Failed to parse session diff", { file, err })
+ return null
+ }
+ })()
const before = view ? text(view, "deletions") : ""
const after = view ? text(view, "additions") : ""
const image = (() => {
@@ -108,7 +116,8 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile {
generatedLike: false,
// A zero-stat empty patch has no text body to fetch; nonzero stats
// indicate a deferred large-file summary.
- summarized: !mime && raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0),
+ summarized:
+ !mime && ((!view && raw.patch !== "") || (raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0))),
kind: mime ? "image" : undefined,
image,
stamp: mime ? fingerprint(raw) : undefined,
diff --git a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts
index ce870f0abbe..081fec45caf 100644
--- a/packages/kilo-vscode/tests/unit/diff-session-source.test.ts
+++ b/packages/kilo-vscode/tests/unit/diff-session-source.test.ts
@@ -91,6 +91,39 @@ describe("createSessionDiffSource.fetch", () => {
expect(result.diffs[2]?.summarized).toBe(true)
})
+ it("keeps valid files visible when one persisted patch is malformed", async () => {
+ const malformed = [
+ "diff --git a/broken.ts b/broken.ts",
+ "--- a/broken.ts",
+ "+++ b/broken.ts",
+ "@@ -1,2 +1,2 @@",
+ "-old",
+ "+new",
+ "",
+ ].join("\n")
+ const { fetch } = recording([
+ { file: "broken.ts", patch: malformed, additions: 1, deletions: 1, status: "modified" },
+ { file: "foo.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" },
+ ])
+
+ const result = await createSessionDiffSource("s-malformed", fetch, "/repo").fetch()
+
+ expect(result.diffs).toHaveLength(2)
+ expect(result.diffs[0]).toMatchObject({
+ file: "broken.ts",
+ before: "",
+ after: "",
+ patch: malformed,
+ summarized: true,
+ })
+ expect(result.diffs[1]).toMatchObject({
+ file: "foo.ts",
+ before: "keep\nold\n",
+ after: "keep\nnew\n",
+ summarized: false,
+ })
+ })
+
it("rebuilds text-backed SVG snapshot sides without sending them to Pierre", async () => {
const before = ''
const after = ''
diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts
index af57a7463dd..82d2b6789cf 100644
--- a/packages/opencode/src/kilocode/snapshot/diff-full.ts
+++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts
@@ -1,8 +1,8 @@
// kilocode_change - new file
//
-// Patch generation. Runs `git diff --unified=INT_MAX` to produce
+// Patch generation. Runs `git diff --unified=3` to produce
// unified-diff text for a set of files, instead of the npm `diff` package's
-// JS Myers implementation. Myers is O(N*M) with full context, so on
+// JS Myers implementation. Myers is O(N*M), so on
// huge-file diffs it can block the event loop for minutes (the TUI freeze
// where ESC stopped working after a turn).
//
@@ -18,8 +18,9 @@ import * as Log from "@opencode-ai/core/util/log"
export namespace DiffFull {
const log = Log.create({ service: "snapshot.diff-full" })
- // INT_MAX — git clamps to this, effectively infinite context.
- const unified = "--unified=2147483647"
+ // Keep context bounded. Git's effectively infinite context can emit
+ // malformed repeated hunks on Windows and makes persisted snapshots huge.
+ const unified = "--unified=3"
interface GitResult {
readonly code: number
@@ -28,7 +29,7 @@ export namespace DiffFull {
}
/**
- * Run `git diff --unified=INT_MAX` for a set of files between two refs and
+ * Run `git diff --unified=3` for a set of files between two refs and
* return a `file → unified-diff text` map. Output format matches what the
* `diff` package's `parsePatch` expects, so downstream clients continue to
* work.
@@ -85,7 +86,7 @@ export namespace DiffFull {
/**
* Generate a structured + unified diff for a single file in the working
- * tree vs HEAD using `git diff --ignore-all-space --unified=INT_MAX`.
+ * tree vs HEAD using `git diff --ignore-all-space --unified=3`.
* Returns `null` if git produces no output (caller emits a content-only
* response with no patch).
*/
diff --git a/packages/opencode/test/kilocode/diff-full.test.ts b/packages/opencode/test/kilocode/diff-full.test.ts
index 0dc228fb95f..3f67cee9237 100644
--- a/packages/opencode/test/kilocode/diff-full.test.ts
+++ b/packages/opencode/test/kilocode/diff-full.test.ts
@@ -4,6 +4,7 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
+import { parsePatch } from "diff"
import { Effect, Layer } from "effect"
import path from "path"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
@@ -106,6 +107,24 @@ describe("DiffFull.batch", () => {
}),
)
+ it.live("keeps distant changes in bounded, parseable hunks", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const before = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`)
+ yield* Effect.promise(() => Filesystem.write(path.join(dir, "multi.txt"), before.join("\n") + "\n"))
+ const from = yield* Effect.promise(() => commit(dir, "v1"))
+ const after = before.with(1, "changed near start").with(27, "changed near end")
+ yield* Effect.promise(() => Filesystem.write(path.join(dir, "multi.txt"), after.join("\n") + "\n"))
+ const to = yield* Effect.promise(() => commit(dir, "v2"))
+
+ const result = yield* DiffFull.batch(gitResult(dir), from, to, ["multi.txt"])
+ const parsed = parsePatch(result.get("multi.txt") ?? "")[0]
+
+ expect(parsed?.hunks).toHaveLength(2)
+ expect(parsed?.hunks.every((hunk) => hunk.lines.length < before.length)).toBe(true)
+ }),
+ )
+
it.live("returns an empty map without spawning for an empty file list", () =>
Effect.gen(function* () {
let calls = 0