diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 94c136415dc2..0ab62d52b995 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -7,6 +7,9 @@ import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" +const MAX_DIFF_RENDER_CHARS = 512 * 1024 +const MAX_DIFF_RENDER_LINES = 5_000 + function todoText(item: { status: string; content: string }): string { if (item.status === "completed") { return `[✓] ${item.content}` @@ -27,6 +30,17 @@ function todoColor(theme: RunTheme, status: string) { return status === "in_progress" ? theme.footer.warning : theme.block.muted } +function tooLargeDiff(diff: string) { + if (diff.length > MAX_DIFF_RENDER_CHARS) return true + let lines = 1 + for (let i = 0; i < diff.length; i++) { + if (diff.charCodeAt(i) !== 10) continue + lines++ + if (lines > MAX_DIFF_RENDER_LINES) return true + } + return false +} + export function entryGroupKey(commit: StreamCommit): string | undefined { if (!commit.partID) { return undefined @@ -193,27 +207,33 @@ export function RunEntryContent(props: { {item.title} {item.diff.trim() ? ( - - - + tooLargeDiff(item.diff) ? ( + + Diff too large to render safely; open the file or inspect the patch from disk. + + ) : ( + + + + ) ) : ( -{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index f974a457ad7b..2064782cdd2c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,6 +1,5 @@ import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { formatPatch, structuredPatch } from "diff" import path from "path" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" @@ -31,6 +30,9 @@ export type FileDiff = typeof FileDiff.Type const log = Log.create({ service: "snapshot" }) const prune = "7.days" const limit = 2 * 1024 * 1024 +const diffPatchLineLimit = 1_000 +const diffPatchByteLimit = 256 * 1024 +const diffPatchTotalByteLimit = 2 * 1024 * 1024 const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] const cfg = ["-c", "core.autocrlf=false", ...core] const quote = [...cfg, "-c", "core.quotepath=false"] @@ -506,133 +508,6 @@ export const layer: Layer.Layer item.text), - ), - ] - } - if (row.status === "deleted") { - return [ - yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), - "", - ] - } - return yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - }) - - const load = Effect.fnUntraced( - function* (rows: Row[]) { - const refs = rows.flatMap((row) => { - if (row.binary) return [] - if (row.status === "added") - return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] - if (row.status === "deleted") { - return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] - } - return [ - { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, - { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, - ] - }) - if (!refs.length) return new Map() - - const batch = yield* appProcess.run( - ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { - cwd: state.directory, - extendEnv: true, - }), - { stdin: refs.map((item) => item.ref).join("\n") + "\n" }, - ) - if (batch.exitCode !== 0) { - log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { - stderr: batch.stderr.toString("utf8"), - refs: refs.length, - }) - return - } - const out = batch.stdout - - const fail = (msg: string, extra?: Record) => { - log.info(msg, { ...extra, refs: refs.length }) - return undefined - } - - const map = new Map() - const dec = new TextDecoder() - let i = 0 - for (const ref of refs) { - let end = i - while (end < out.length && out[end] !== 10) end += 1 - if (end >= out.length) { - return fail( - "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", - ) - } - - const head = dec.decode(out.slice(i, end)) - i = end + 1 - const hit = map.get(ref.file) ?? { before: "", after: "" } - if (head.endsWith(" missing")) { - map.set(ref.file, hit) - continue - } - - const match = head.match(/^[0-9a-f]+ blob (\d+)$/) - if (!match) { - return fail( - "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const size = Number(match[1]) - if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { - return fail( - "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const text = dec.decode(out.slice(i, i + size)) - if (ref.side === "before") hit.before = text - if (ref.side === "after") hit.after = text - map.set(ref.file, hit) - i += size + 1 - } - - if (i !== out.length) { - return fail( - "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", - ) - } - - return map - }, - Effect.scoped, - Effect.catch(() => - Effect.succeed | undefined>(undefined), - ), - ) - const result: FileDiff[] = [] const status = new Map() @@ -684,25 +559,69 @@ export const layer: Layer.Layer - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + let totalPatchBytes = 0 + const patch = Effect.fnUntraced(function* (row: Row) { + if (row.binary) return "" + const changed = row.additions + row.deletions + if (changed > diffPatchLineLimit) { + log.info("snapshot diff patch skipped because changed line limit was exceeded", { + file: row.file, + changed, + max: diffPatchLineLimit, + }) + return + } + if (totalPatchBytes >= diffPatchTotalByteLimit) return - for (let i = 0; i < rows.length; i += step) { - const run = rows.slice(i, i + step) - const text = yield* load(run) + const patch = yield* git( + [ + ...quote, + ...args(["diff", "--no-ext-diff", "--no-renames", "--unified=3", from, to, "--", row.file]), + ], + { cwd: state.directory }, + ) + if (patch.code !== 0) { + log.warn("failed to get snapshot file patch", { + file: row.file, + exitCode: patch.code, + stderr: patch.stderr, + }) + return + } - for (const row of run) { - const hit = text?.get(row.file) ?? { before: "", after: "" } - const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) - result.push({ + const text = patch.text.trimEnd() + if (!text) return "" + const bytes = Buffer.byteLength(text) + if (bytes > diffPatchByteLimit) { + log.info("snapshot diff patch skipped because byte limit was exceeded", { + file: row.file, + bytes, + max: diffPatchByteLimit, + }) + return + } + if (totalPatchBytes + bytes > diffPatchTotalByteLimit) { + log.info("snapshot diff patch skipped because total byte limit was exceeded", { file: row.file, - patch: row.binary ? "" : patch(row.file, before, after), - additions: row.additions, - deletions: row.deletions, - status: row.status, + bytes: totalPatchBytes + bytes, + max: diffPatchTotalByteLimit, }) + return } + + totalPatchBytes += bytes + return text + }) + + for (const row of rows) { + const item = yield* patch(row) + result.push({ + file: row.file, + ...(item === undefined ? {} : { patch: item }), + additions: row.additions, + deletions: row.deletions, + status: row.status, + }) } return result diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 8b4219195243..addf73436888 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -884,6 +884,25 @@ it.instance( { git: true }, ) +it.instance( + "diffFull omits patches that are too large to render safely", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + const lines = Array.from({ length: 1_200 }, (_, i) => `line-${i}`).join("\n") + yield* write(`${tmp.path}/large.txt`, lines) + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before, after!) + const diff = diffs.find((item) => item.file === "large.txt") + expect(diff).toBeDefined() + expect(diff!.additions).toBe(1_200) + expect(diff!.deletions).toBe(0) + expect(diff!.patch).toBeUndefined() + }), + ), + { git: true }, +) + it.instance( "diffFull with file modifications", withTrackedSnapshot(({ tmp, snapshot, before }) => diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a39b9a7f6438..c08deab7d98a 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -459,8 +459,8 @@ export function SessionTurn( > {(diff) => { - const view = normalize(diff) const active = createMemo(() => expanded().includes(diff.file)) + const view = createMemo(() => normalize(diff)) const [shown, setShown] = createSignal(false) createEffect( @@ -508,7 +508,7 @@ export function SessionTurn(
- +