Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/safe-windows-snapshot-diffs.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions packages/kilo-vscode/src/diff/sources/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (() => {
Expand All @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/kilo-vscode/tests/unit/diff-session-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect fill="red"/></svg>'
const after = '<svg xmlns="http://www.w3.org/2000/svg"><rect fill="blue"/></svg>'
Expand Down
13 changes: 7 additions & 6 deletions packages/opencode/src/kilocode/snapshot/diff-full.ts
Original file line number Diff line number Diff line change
@@ -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).
//
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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).
*/
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/kilocode/diff-full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading