From 3ef395a01847ae2528fd81da60ac40bee0c9dfaf Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 7 Sep 2026 14:03:02 +0200 Subject: [PATCH 1/2] fix(vscode): respect Git diff collapse attributes --- .changeset/git-diff-attributes.md | 5 + .../src/agent-manager/local-diff-cache.ts | 1 + .../src/agent-manager/local-diff.ts | 73 ++++----------- .../src/diff/shared/git-attributes.ts | 92 +++++++++++++++++++ .../kilo-vscode/src/diff/sources/catalog.ts | 29 ++++-- .../src/diff/sources/git-status.ts | 23 ++++- .../kilo-vscode/src/diff/sources/session.ts | 11 ++- .../kilo-vscode/src/diff/sources/staged.ts | 13 ++- packages/kilo-vscode/src/diff/sources/turn.ts | 5 +- .../kilo-vscode/src/diff/sources/unstaged.ts | 20 ++-- .../unit/agent-manager-diff-state.test.ts | 12 ++- .../tests/unit/diff-session-source.test.ts | 18 ++++ .../tests/unit/diff-turn-source.test.ts | 18 ++++ .../kilo-vscode/tests/unit/local-diff.test.ts | 22 +++++ .../diff-viewer/diff-open-policy.ts | 11 ++- 15 files changed, 269 insertions(+), 84 deletions(-) create mode 100644 .changeset/git-diff-attributes.md create mode 100644 packages/kilo-vscode/src/diff/shared/git-attributes.ts diff --git a/.changeset/git-diff-attributes.md b/.changeset/git-diff-attributes.md new file mode 100644 index 000000000000..8cacf9a9745b --- /dev/null +++ b/.changeset/git-diff-attributes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Collapse files marked `linguist-generated` in repository `.gitattributes` rules while keeping explicitly visible files such as English and German translations expanded by default. diff --git a/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts b/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts index 601c119c8656..1e49dc10cb81 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff-cache.ts @@ -69,6 +69,7 @@ export function createDiffCache(load: Loader) { meta.additions, meta.deletions, meta.binary, + meta.generatedLike, meta.stamp, ) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 4ec4a9706e8d..4a24fd6d14cb 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -1,4 +1,5 @@ import * as fs from "fs/promises" +import { classifyGenerated, generatedLike, gitGeneratedFiles } from "../diff/shared/git-attributes" import { imageMime, loadImage, readImageFile } from "../diff/shared/image" import { resolveInside } from "../diff/shared/path" import type { GitOps } from "./GitOps" @@ -32,54 +33,7 @@ const MAX_SUMMARY_FILES = 32 /** Ported from `packages/opencode/src/file/ignore.ts` — identical patterns, * no runtime dependency on minimatch/picomatch. */ -const FOLDERS = new Set([ - "node_modules", - "bower_components", - ".pnpm-store", - "vendor", - ".npm", - "dist", - "build", - "out", - ".next", - "target", - "bin", - "obj", - ".git", - ".svn", - ".hg", - ".vscode", - ".idea", - ".turbo", - ".output", - "desktop", - ".sst", - ".cache", - ".webkit-cache", - "__pycache__", - ".pytest_cache", - "mypy_cache", - ".history", - ".gradle", -]) - -const SUFFIXES = [".swp", ".swo", ".pyc", ".log"] -const BASENAMES = new Set([".DS_Store", "Thumbs.db"]) -const CONTAINS_SEGMENTS = ["logs", "tmp", "temp", "coverage", ".nyc_output"] - -export function generatedLike(file: string): boolean { - const parts = file.split(/[/\\]/) - for (const part of parts) { - if (FOLDERS.has(part)) return true - if (CONTAINS_SEGMENTS.includes(part)) return true - } - for (const suffix of SUFFIXES) { - if (file.endsWith(suffix)) return true - } - const base = parts[parts.length - 1] ?? "" - if (BASENAMES.has(base)) return true - return false -} +export { generatedLike } from "../diff/shared/git-attributes" const BASE_CANDIDATES = ["main", "master", "dev", "develop"] @@ -173,6 +127,18 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { + const markGenerated = async (entries: Meta[]) => { + const configured = await gitGeneratedFiles( + git, + dir, + entries.map((entry) => entry.file), + ) + return entries.map((entry) => ({ + ...entry, + generatedLike: classifyGenerated(entry.file, configured), + })) + } + const [tracked, untracked] = await Promise.all([ git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir, { priority: true, @@ -224,11 +190,11 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise file && !seen.has(file)) for (let index = 0; index < paths.length; index += MAX_SUMMARY_FILES) { @@ -255,7 +221,7 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { const full = resolveInside(dir, file) if (!full) return undefined + const configured = await gitGeneratedFiles(git, dir, [file], { signal }) const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir, { signal, priority: true }) check(signal) if (tracked.code !== 0) { @@ -312,7 +279,7 @@ async function detailMeta( deletions: 0, status: "added", tracked: false, - generatedLike: generatedLike(file), + generatedLike: classifyGenerated(file, configured), binary: value.binary, stamp: value.stamp, } @@ -341,7 +308,7 @@ async function detailMeta( deletions: stat.deletions, status, tracked: true, - generatedLike: generatedLike(pathPart), + generatedLike: classifyGenerated(pathPart, configured), binary: stat.binary, stamp: status === "deleted" diff --git a/packages/kilo-vscode/src/diff/shared/git-attributes.ts b/packages/kilo-vscode/src/diff/shared/git-attributes.ts new file mode 100644 index 000000000000..1409c6ebd1ad --- /dev/null +++ b/packages/kilo-vscode/src/diff/shared/git-attributes.ts @@ -0,0 +1,92 @@ +import type { GitOps } from "../../agent-manager/GitOps" + +const ATTRIBUTE = "linguist-generated" +const FOLDERS = new Set([ + "node_modules", + "bower_components", + ".pnpm-store", + "vendor", + ".npm", + "dist", + "build", + "out", + ".next", + "target", + "bin", + "obj", + ".git", + ".svn", + ".hg", + ".vscode", + ".idea", + ".turbo", + ".output", + "desktop", + ".sst", + ".cache", + ".webkit-cache", + "__pycache__", + ".pytest_cache", + "mypy_cache", + ".history", + ".gradle", +]) + +const SUFFIXES = [".swp", ".swo", ".pyc", ".log"] +const BASENAMES = new Set([".DS_Store", "Thumbs.db"]) +const CONTAINS_SEGMENTS = ["logs", "tmp", "temp", "coverage", ".nyc_output"] + +export type GeneratedAttributes = ReadonlyMap +export type GeneratedFiles = (files: readonly string[]) => Promise + +export function generatedLike(file: string): boolean { + const parts = file.split(/[/\\]/) + for (const part of parts) { + if (FOLDERS.has(part)) return true + if (CONTAINS_SEGMENTS.includes(part)) return true + } + for (const suffix of SUFFIXES) { + if (file.endsWith(suffix)) return true + } + const base = parts[parts.length - 1] ?? "" + return BASENAMES.has(base) +} + +export function classifyGenerated(file: string, attrs?: GeneratedAttributes): boolean { + return attrs?.get(file) ?? generatedLike(file) +} + +/** + * Read the repository's generated-file attributes for a set of paths. + * GitHub Linguist uses `linguist-generated`, and repositories already keep + * those rules in `.gitattributes` for pull-request diffs. + */ +export async function gitGeneratedFiles( + git: GitOps, + dir: string, + files: readonly string[], + options: { cached?: boolean; signal?: AbortSignal } = {}, +): Promise> { + const paths = [...new Set(files.filter(Boolean))] + if (paths.length === 0) return new Map() + + const args = ["check-attr"] + if (options.cached) args.push("--cached") + args.push("-z", "--stdin", ATTRIBUTE) + const result = await git.execGit(args, dir, { + stdin: `${paths.join("\0")}\0`, + signal: options.signal, + priority: true, + }) + if (result.code !== 0) return new Map() + + const attrs = new Map() + const fields = result.stdout.split("\0") + for (let field = 0; field + 2 < fields.length; field += 3) { + if (fields[field + 1] !== ATTRIBUTE) continue + const value = fields[field + 2] + if (value === "true" || value === "set") attrs.set(fields[field]!, true) + if (value === "false" || value === "unset") attrs.set(fields[field]!, false) + } + return attrs +} diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 1cfb414a8cfd..0ab753a88d91 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "../../services/cli-backend" import { GitOps } from "../../agent-manager/GitOps" +import { gitGeneratedFiles } from "../shared/git-attributes" import { resolveLocalDiffTarget } from "../shared/target" import { appendOutput, getWorkspaceRoot } from "../../review-utils" import type { BranchListItem } from "../../agent-manager/git-import" @@ -73,6 +74,8 @@ export class DiffSourceCatalog implements vscode.Disposable { // owned by the catalog so it survives source swaps. private branchGit: GitOps | undefined private branchOutput: vscode.OutputChannel | undefined + private attributeGit: GitOps | undefined + private attributeOutput: vscode.OutputChannel | undefined constructor( private readonly connection: KiloConnectionService, @@ -100,6 +103,10 @@ export class DiffSourceCatalog implements vscode.Disposable { build(id: string, ctx: PanelContext): DiffSource { const opts = { dir: () => ctx.dir, strictDir: ctx.strictDir, git: ctx.git, log: ctx.log } + const dir = ctx.dir ?? ctx.workspaceRoot + const generated = dir + ? (files: readonly string[]) => gitGeneratedFiles(ctx.git ?? this.ensureAttributeGit(), dir, files) + : undefined if (id === WORKSPACE_SOURCE_ID) { return createWorktreeDiffSource({ ...opts, @@ -118,18 +125,13 @@ export class DiffSourceCatalog implements vscode.Disposable { if (!sessionId || !messageId) { throw new Error(`DiffSourceCatalog.build: malformed turn id "${id}" (expected turn::)`) } - return createTurnDiffSource(sessionId, messageId, this.turnFetch, ctx.workspaceRoot) + return createTurnDiffSource(sessionId, messageId, this.turnFetch, dir, generated) } if (id.startsWith(SESSION_PREFIX)) { const sessionId = id.slice(SESSION_PREFIX.length) if (!sessionId) throw new Error(`DiffSourceCatalog.build: empty session id in "${id}"`) - return createSessionDiffSource( - sessionId, - this.sessionFetch, - ctx.dir ?? ctx.workspaceRoot, - this.checkSnapshotsEnabled, - ) + return createSessionDiffSource(sessionId, this.sessionFetch, dir, this.checkSnapshotsEnabled, generated) } throw new Error(`DiffSourceCatalog.build: unknown source id "${id}"`) @@ -164,8 +166,12 @@ export class DiffSourceCatalog implements vscode.Disposable { dispose(): void { this.branchGit?.dispose() this.branchGit = undefined + this.attributeGit?.dispose() + this.attributeGit = undefined this.branchOutput?.dispose() this.branchOutput = undefined + this.attributeOutput?.dispose() + this.attributeOutput = undefined } private readonly branchLog = (...args: unknown[]) => { @@ -179,4 +185,13 @@ export class DiffSourceCatalog implements vscode.Disposable { this.branchGit = new GitOps({ log: this.branchLog }) return this.branchGit } + + private ensureAttributeGit(): GitOps { + if (this.attributeGit) return this.attributeGit + this.attributeOutput = vscode.window.createOutputChannel("Kilo Diff: Attributes") + this.attributeGit = new GitOps({ + log: (...args) => appendOutput(this.attributeOutput!, "DiffSourceCatalog", ...args), + }) + return this.attributeGit + } } diff --git a/packages/kilo-vscode/src/diff/sources/git-status.ts b/packages/kilo-vscode/src/diff/sources/git-status.ts index b6844af9aa1f..20ccf72ebeee 100644 --- a/packages/kilo-vscode/src/diff/sources/git-status.ts +++ b/packages/kilo-vscode/src/diff/sources/git-status.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises" import type { GitOps } from "../../agent-manager/GitOps" -import { generatedLike } from "../../agent-manager/local-diff" +import { classifyGenerated, generatedLike, gitGeneratedFiles } from "../shared/git-attributes" import { imageMime, readImageFile } from "../shared/image" import { resolveInside } from "../shared/path" import type { DiffFile } from "../types" @@ -19,9 +19,28 @@ export interface FileEntry { deletions: number tracked: boolean binary: boolean + generatedLike?: boolean stamp?: string } +export async function applyGeneratedAttributes( + git: GitOps, + dir: string, + entries: FileEntry[], + cached = false, +): Promise { + const configured = await gitGeneratedFiles( + git, + dir, + entries.map((entry) => entry.file), + { cached }, + ) + return entries.map((entry) => ({ + ...entry, + generatedLike: classifyGenerated(entry.file, configured), + })) +} + /** Parse `git diff --name-status` output into entries (status code + path). */ export function parseNameStatus(stdout: string): { file: string; status: Status }[] { const out: { file: string; status: Status }[] = [] @@ -85,7 +104,7 @@ export function summarize(entry: FileEntry): DiffFile { deletions: entry.deletions, status: entry.status, tracked: entry.tracked, - generatedLike: generatedLike(entry.file), + generatedLike: entry.generatedLike ?? generatedLike(entry.file), // Binary metadata is complete because no deferred text body exists. // Images are the exception: their encoded sides load lazily on expansion. summarized: image || !entry.binary, diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts index 924c232659d2..42f3338c58cc 100644 --- a/packages/kilo-vscode/src/diff/sources/session.ts +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto" import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" import { normalize, text } from "@kilocode/kilo-ui/session-diff" import { encodeImageSide, imageMime } from "../shared/image" +import { classifyGenerated, type GeneratedAttributes, type GeneratedFiles } from "../shared/git-attributes" import type { DiffFile } from "../types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" @@ -35,6 +36,7 @@ export function createSessionDiffSource( fetch: SessionDiffFetch, workspaceRoot?: string, checkSnapshotsEnabled?: SnapshotEnabledCheck, + generated?: GeneratedFiles, ): DiffSource { // Cached across fetches so subsequent polling ticks skip the config lookup. let snapshotsDisabled = false @@ -57,9 +59,10 @@ export function createSessionDiffSource( } const raw = await fetch({ sessionID: sessionId, directory: workspaceRoot }) - const key = raw.map(fingerprint).join("|") + const configured = generated ? await generated(raw.map((file) => file.file ?? "")) : undefined + const key = `${raw.map(fingerprint).join("|")}\0${configured ? [...configured].sort().join("|") : ""}` if (cache?.key === key) return { diffs: cache.diffs } - const diffs = raw.map(toSessionDiffFile) + const diffs = raw.map((file) => toSessionDiffFile(file, configured)) cache = { key, diffs } return { diffs } }, @@ -77,7 +80,7 @@ function fingerprint(raw: SnapshotFileDiff): string { * Project a backend `SnapshotFileDiff` onto the `DiffFile` shape the viewer * expects. Shared with `createTurnDiffSource` since both hit the same endpoint. */ -export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { +export function toSessionDiffFile(raw: SnapshotFileDiff, generated?: GeneratedAttributes): DiffFile { const file = raw.file ?? "" const mime = imageMime(file) // Empty patch means binary or summarized (>256 KB) — normalize() can't @@ -113,7 +116,7 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { deletions: raw.deletions, status: raw.status, tracked: true, - generatedLike: false, + generatedLike: classifyGenerated(file, generated), // A zero-stat empty patch has no text body to fetch; nonzero stats // indicate a deferred large-file summary. summarized: diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index 8cd250743cb8..8eb82a8a4ec1 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode" import { GitOps } from "../../agent-manager/GitOps" -import { generatedLike } from "../../agent-manager/local-diff" import { appendOutput, getWorkspaceRoot } from "../../review-utils" import { imageMime, loadImage } from "../shared/image" import { resolveInside } from "../shared/path" @@ -9,6 +8,7 @@ import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" import { blobOid, blobSize, + applyGeneratedAttributes, INDEX_REF, MAX_DETAIL_BYTES, parseNameStatus, @@ -83,7 +83,7 @@ export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): Diff } const counts = parseNumstat(numstat.code === 0 ? numstat.stdout : "") const refs = parseRawOids(raw.code === 0 ? raw.stdout : "") - return parseNameStatus(nameStatus.stdout).map((item) => { + const entries = parseNameStatus(nameStatus.stdout).map((item) => { const ref = refs.get(item.file) const entry = { file: item.file, @@ -99,6 +99,7 @@ export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): Diff item.status === "deleted" ? "missing" : (ref?.after ?? "missing"), ) }) + return applyGeneratedAttributes(git, dir, entries, true) } return { @@ -165,7 +166,7 @@ export function createStagedDiffSource(opts: StagedDiffSourceOptions = {}): Diff deletions: entry.deletions, status: entry.status, tracked: true, - generatedLike: generatedLike(file), + generatedLike: entry.generatedLike, summarized, stamp: entry.stamp ?? `${entry.status}:${entry.additions}:${entry.deletions}`, } @@ -211,10 +212,12 @@ async function fileEntry( tracked: true, binary: stats.get(item.file)?.binary ?? false, } - if (!imageMime(item.file)) return entry + const marked = (await applyGeneratedAttributes(git, dir, [entry], true)).at(0) + if (!marked) return undefined + if (!imageMime(item.file)) return marked const [before, after] = await Promise.all([ item.status === "added" ? "missing" : blobOid(git, dir, "HEAD", item.file), item.status === "deleted" ? "missing" : blobOid(git, dir, INDEX_REF, item.file), ]) - return stamp(entry, before, after) + return stamp(marked, before, after) } diff --git a/packages/kilo-vscode/src/diff/sources/turn.ts b/packages/kilo-vscode/src/diff/sources/turn.ts index 24b6b5e1bc98..7c2ffff2fa2e 100644 --- a/packages/kilo-vscode/src/diff/sources/turn.ts +++ b/packages/kilo-vscode/src/diff/sources/turn.ts @@ -1,5 +1,6 @@ import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" +import type { GeneratedFiles } from "../shared/git-attributes" import { toSessionDiffFile } from "./session" export const TURN_PREFIX = "turn:" @@ -41,13 +42,15 @@ export function createTurnDiffSource( messageId: string, fetch: TurnDiffFetch, workspaceRoot?: string, + generated?: GeneratedFiles, ): DiffSource { return { descriptor: turnDescriptor(sessionId, messageId), async fetch(): Promise { const raw = await fetch({ sessionID: sessionId, messageID: messageId, directory: workspaceRoot }) - return { diffs: raw.map(toSessionDiffFile), stopPolling: true } + const configured = generated ? await generated(raw.map((file) => file.file ?? "")) : undefined + return { diffs: raw.map((file) => toSessionDiffFile(file, configured)), stopPolling: true } }, } } diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index 5fcb03793d2a..470e39b7408d 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -1,7 +1,6 @@ import * as fs from "fs/promises" import * as vscode from "vscode" import { GitOps } from "../../agent-manager/GitOps" -import { generatedLike } from "../../agent-manager/local-diff" import { appendOutput, getWorkspaceRoot } from "../../review-utils" import { binaryFile } from "../shared/binary" import { imageMime, loadImage } from "../shared/image" @@ -11,6 +10,7 @@ import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types" import { blobOid, blobSize, + applyGeneratedAttributes, diskStamp, fileSize, INDEX_REF, @@ -153,8 +153,9 @@ export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): // defensive — git can race here when files are added concurrently). const seen = new Set(tracked.map((t) => t.file)) const merged = tracked.concat(untracked.filter((u) => !seen.has(u.file))) - log(`Unstaged diff: ${merged.length} file(s) (${tracked.length} tracked, ${untracked.length} untracked)`) - return { diffs: merged.map(summarize) } + const marked = await applyGeneratedAttributes(git, dir, merged) + log(`Unstaged diff: ${marked.length} file(s) (${tracked.length} tracked, ${untracked.length} untracked)`) + return { diffs: marked.map(summarize) } }, async fetchFile(file: string): Promise { @@ -203,7 +204,7 @@ export function createUnstagedDiffSource(opts: UnstagedDiffSourceOptions = {}): deletions: entry.deletions, status: entry.status, tracked: entry.tracked, - generatedLike: generatedLike(file), + generatedLike: entry.generatedLike, summarized, // Match the summary stamp so cache invalidation is consistent across // summarize → fetchFile transitions. `entry.stamp` is set for @@ -275,12 +276,14 @@ async function fileEntry( tracked: true, binary: stats.get(item.file)?.binary ?? false, } - if (!imageMime(item.file)) return entry + const marked = (await applyGeneratedAttributes(git, dir, [entry])).at(0) + if (!marked) return undefined + if (!imageMime(item.file)) return marked const [before, after] = await Promise.all([ item.status === "added" ? "missing" : blobOid(git, dir, INDEX_REF, item.file), item.status === "deleted" ? "missing" : diskStamp(dir, item.file), ]) - return stamp(entry, before, after) + return stamp(marked, before, after) } } @@ -300,15 +303,16 @@ async function fileEntry( log("Unstaged file not found", { file }) return undefined } - return { + const entry = { file, - status: "added", + status: "added" as const, additions: 0, deletions: 0, tracked: false, binary: await binaryFile(full), stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`, } + return (await applyGeneratedAttributes(git, dir, [entry])).at(0) } function lineCount(text: string): number { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts index 91d129f1a474..83e094d6353a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts @@ -126,7 +126,7 @@ describe("agent manager diff state", () => { expect(result.stale).toEqual(new Set(["src/app.ts"])) }) - it("opens every diff initially", () => { + it("opens reviewable diffs initially while keeping generated files collapsed", () => { expect( initialOpenFiles([ diff({ file: "src/app.ts", generatedLike: false, additions: 3 }), @@ -135,7 +135,7 @@ describe("agent manager diff state", () => { diff({ file: "assets/banner.png", kind: "image", summarized: true, additions: 0 }), diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }), ]), - ).toEqual(["src/app.ts", "node_modules/pkg/index.js", "src/huge.ts"]) + ).toEqual(["src/app.ts", "src/huge.ts"]) const many = Array.from({ length: 26 }, (_, i) => diff({ file: `src/${i}.ts` })) expect(initialOpenFiles(many)).toHaveLength(26) @@ -188,6 +188,14 @@ describe("agent manager diff state", () => { }) }) + it("keeps newly arriving generated files collapsed", () => { + const current = [diff({ file: "src/app.ts" }), diff({ file: "src/generated.ts", generatedLike: true })] + expect(reconcileOpenFiles(current, ["src/app.ts"], ["src/app.ts"])).toEqual({ + open: ["src/app.ts"], + known: ["src/app.ts", "src/generated.ts"], + }) + }) + it("does not initialize a manual empty snapshot until the first state exists", () => { const current = [diff({ file: "src/app.ts" })] expect(reconcileOpenFiles(current, undefined, [])).toEqual({ open: undefined, known: ["src/app.ts"] }) 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 081fec45caf4..4846d94da0e9 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,24 @@ describe("createSessionDiffSource.fetch", () => { expect(result.diffs[2]?.summarized).toBe(true) }) + it("uses repository generated-file classifications for session diffs", async () => { + const { fetch } = recording([ + { file: "i18n/en.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + { file: "i18n/fr.ts", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + { file: "dist/bundle.js", patch: modifiedPatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const generated = async (files: readonly string[]) => + new Map(files.filter((file) => file === "i18n/fr.ts").map((file) => [file, true])) + + const result = await createSessionDiffSource("s-generated", fetch, "/repo", undefined, generated).fetch() + + expect(result.diffs.map((diff) => [diff.file, diff.generatedLike])).toEqual([ + ["i18n/en.ts", false], + ["i18n/fr.ts", true], + ["dist/bundle.js", true], + ]) + }) + it("keeps valid files visible when one persisted patch is malformed", async () => { const malformed = [ "diff --git a/broken.ts b/broken.ts", diff --git a/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts b/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts index 55e5055d07e6..b17cb002336a 100644 --- a/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts +++ b/packages/kilo-vscode/tests/unit/diff-turn-source.test.ts @@ -55,6 +55,24 @@ describe("createTurnDiffSource.fetch", () => { expect(result.diffs[0]!.after).toBe("new\n") }) + it("uses repository generated-file classifications", async () => { + const { fetch } = recording([ + { file: "i18n/en.ts", patch: samplePatch, additions: 1, deletions: 1, status: "modified" }, + { file: "i18n/fr.ts", patch: samplePatch, additions: 1, deletions: 1, status: "modified" }, + { file: "dist/bundle.js", patch: samplePatch, additions: 1, deletions: 1, status: "modified" }, + ]) + const generated = async (files: readonly string[]) => + new Map(files.filter((file) => file === "i18n/fr.ts").map((file) => [file, true])) + + const result = await createTurnDiffSource("sess", "msg", fetch, "/repo", generated).fetch() + + expect(result.diffs.map((diff) => [diff.file, diff.generatedLike])).toEqual([ + ["i18n/en.ts", false], + ["i18n/fr.ts", true], + ["dist/bundle.js", true], + ]) + }) + it("propagates underlying fetch errors", async () => { const { fetch } = recording(new Error("backend unavailable")) const source = createTurnDiffSource("sess", "msg", fetch) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 37ef8b572610..fca7a8309e41 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -374,6 +374,28 @@ describe("diffSummary", () => { expect(src?.generatedLike).toBe(false) }) }) + + it("uses linguist-generated attributes and keeps English and German visible", async () => { + await withRepo(async (dir, base) => { + await fs.mkdir(path.join(dir, "i18n"), { recursive: true }) + await fs.writeFile( + path.join(dir, ".gitattributes"), + "**/i18n/*.ts linguist-generated=true\n**/i18n/en*.ts linguist-generated=false\n**/i18n/de*.ts linguist-generated=false\ndist/*.js linguist-generated=false\n", + ) + await fs.mkdir(path.join(dir, "dist"), { recursive: true }) + await Promise.all( + ["en", "de", "fr"].map((locale) => fs.writeFile(path.join(dir, "i18n", `${locale}.ts`), `${locale}\n`)), + ) + await fs.writeFile(path.join(dir, "dist", "app.js"), "source\n") + + const result = await diffSummary(git(), dir, base) + expect(result.find((entry) => entry.file === "i18n/en.ts")?.generatedLike).toBe(false) + expect(result.find((entry) => entry.file === "i18n/de.ts")?.generatedLike).toBe(false) + expect(result.find((entry) => entry.file === "i18n/fr.ts")?.generatedLike).toBe(true) + expect(result.find((entry) => entry.file === "dist/app.js")?.generatedLike).toBe(false) + expect((await diffFile(git(), dir, base, "i18n/fr.ts"))?.generatedLike).toBe(true) + }) + }) }) describe("createLocalDiff summary cache", () => { diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts index d3b379ce4da5..302fc9095203 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-open-policy.ts @@ -29,8 +29,14 @@ export function expandableOpenFiles(diffs: WorktreeFileDiff[]): string[] { return diffs.filter(isDiffExpandable).map((diff) => diff.file) } +function defaultOpenFiles(diffs: WorktreeFileDiff[]): string[] { + return diffs + .filter((diff) => diff.kind !== "image" && diff.generatedLike !== true && isDiffExpandable(diff)) + .map((diff) => diff.file) +} + export function initialOpenFiles(diffs: WorktreeFileDiff[]): string[] { - return diffs.filter((diff) => diff.kind !== "image" && isDiffExpandable(diff)).map((diff) => diff.file) + return defaultOpenFiles(diffs) } export function reconcileOpenFiles( @@ -41,7 +47,8 @@ export function reconcileOpenFiles( const files = expandableOpenFiles(diffs) if (!manual) return { open: undefined, known: files } const previous = new Set(known) - const added = files.filter((file) => !previous.has(file)) + const defaults = new Set(defaultOpenFiles(diffs)) + const added = files.filter((file) => !previous.has(file) && defaults.has(file)) return { open: sanitizeOpenFiles(diffs, [...manual, ...added]), known: files } } From 4a5c40217a1b2ae171e1f91384b90e598f5343df Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 7 Sep 2026 14:13:21 +0200 Subject: [PATCH 2/2] fix(vscode): remove diff source duplication --- .../kilo-vscode/src/diff/sources/git-status.ts | 15 +++++++++++++++ packages/kilo-vscode/src/diff/sources/staged.ts | 10 ++-------- packages/kilo-vscode/src/diff/sources/unstaged.ts | 10 ++-------- script/kilocode-duplication-allowlist.json | 9 --------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/kilo-vscode/src/diff/sources/git-status.ts b/packages/kilo-vscode/src/diff/sources/git-status.ts index 20ccf72ebeee..9c8b5dd47b86 100644 --- a/packages/kilo-vscode/src/diff/sources/git-status.ts +++ b/packages/kilo-vscode/src/diff/sources/git-status.ts @@ -41,6 +41,21 @@ export async function applyGeneratedAttributes( })) } +export function createFileEntry( + item: { file: string; status: Status }, + stats: Map, +): FileEntry { + const stat = stats.get(item.file) + return { + file: item.file, + status: item.status, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + tracked: true, + binary: stat?.binary ?? false, + } +} + /** Parse `git diff --name-status` output into entries (status code + path). */ export function parseNameStatus(stdout: string): { file: string; status: Status }[] { const out: { file: string; status: Status }[] = [] diff --git a/packages/kilo-vscode/src/diff/sources/staged.ts b/packages/kilo-vscode/src/diff/sources/staged.ts index 8eb82a8a4ec1..9decc9df983d 100644 --- a/packages/kilo-vscode/src/diff/sources/staged.ts +++ b/packages/kilo-vscode/src/diff/sources/staged.ts @@ -9,6 +9,7 @@ import { blobOid, blobSize, applyGeneratedAttributes, + createFileEntry, INDEX_REF, MAX_DETAIL_BYTES, parseNameStatus, @@ -204,14 +205,7 @@ async function fileEntry( dir, ) const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") - const entry = { - file: item.file, - status: item.status, - additions: stats.get(item.file)?.additions ?? 0, - deletions: stats.get(item.file)?.deletions ?? 0, - tracked: true, - binary: stats.get(item.file)?.binary ?? false, - } + const entry = createFileEntry(item, stats) const marked = (await applyGeneratedAttributes(git, dir, [entry], true)).at(0) if (!marked) return undefined if (!imageMime(item.file)) return marked diff --git a/packages/kilo-vscode/src/diff/sources/unstaged.ts b/packages/kilo-vscode/src/diff/sources/unstaged.ts index 470e39b7408d..472cec42dfc6 100644 --- a/packages/kilo-vscode/src/diff/sources/unstaged.ts +++ b/packages/kilo-vscode/src/diff/sources/unstaged.ts @@ -11,6 +11,7 @@ import { blobOid, blobSize, applyGeneratedAttributes, + createFileEntry, diskStamp, fileSize, INDEX_REF, @@ -268,14 +269,7 @@ async function fileEntry( dir, ) const stats = parseNumstat(counts.code === 0 ? counts.stdout : "") - const entry = { - file: item.file, - status: item.status, - additions: stats.get(item.file)?.additions ?? 0, - deletions: stats.get(item.file)?.deletions ?? 0, - tracked: true, - binary: stats.get(item.file)?.binary ?? false, - } + const entry = createFileEntry(item, stats) const marked = (await applyGeneratedAttributes(git, dir, [entry])).at(0) if (!marked) return undefined if (!imageMime(item.file)) return marked diff --git a/script/kilocode-duplication-allowlist.json b/script/kilocode-duplication-allowlist.json index 0515602410cb..b9df48334c17 100644 --- a/script/kilocode-duplication-allowlist.json +++ b/script/kilocode-duplication-allowlist.json @@ -104,15 +104,6 @@ "owner": "kilo-vscode", "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." }, - { - "files": ["packages/kilo-vscode/src/diff/sources/staged.ts", "packages/kilo-vscode/src/diff/sources/unstaged.ts"], - "fingerprint": "2aad5496387de63a", - "maxMatches": 1, - "maxTokens": 131, - "kind": "legacy", - "owner": "kilo-vscode", - "reason": "Existing duplication before the ratchet; remove through a focused, behavior-preserving extraction." - }, { "files": [ "packages/kilo-vscode/webview-ui/src/context/session-variant-store.ts",