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