From 0c72dddc81a7655923079d31ad84aea76e541829 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 3 Sep 2026 10:19:15 +0200 Subject: [PATCH 1/2] fix(vscode): cache and bound change badge file reads --- .changeset/quiet-badge-scans.md | 5 ++ .../kilo-vscode/src/agent-manager/GitOps.ts | 16 +--- .../src/agent-manager/git-stats-snapshot.ts | 46 +++++++--- .../tests/unit/git-stats-snapshot.test.ts | 85 ++++++++++++++++++- 4 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 .changeset/quiet-badge-scans.md diff --git a/.changeset/quiet-badge-scans.md b/.changeset/quiet-badge-scans.md new file mode 100644 index 000000000000..d8102d9fa412 --- /dev/null +++ b/.changeset/quiet-badge-scans.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce change-badge overhead in large repositories by reusing unchanged file counts and limiting concurrent file reads. diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 6ca94f6a082d..be7f3f87e669 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -12,6 +12,7 @@ import { type BranchListItem, } from "./git-import" import type { Semaphore } from "./semaphore" +import { lines } from "./git-stats-snapshot" interface GitOpsOptions { log: (...args: unknown[]) => void @@ -388,20 +389,7 @@ export class GitOps { if (!untracked) return tracked const paths = untracked.split("\n").filter((line) => line.trim()) - const counts = await Promise.all( - paths.map(async (p) => { - try { - const full = nodePath.resolve(cwd, p) - const stat = await fs.stat(full) - if (stat.size > 1_000_000) return 0 - const content = await fs.readFile(full, "utf-8") - return content.split("\n").length - } catch (err) { - this.log(`Failed to read untracked file ${p}:`, err) - return 0 - } - }), - ) + const counts = await Promise.all(paths.map((file) => lines(nodePath.resolve(cwd, file)))) return { files: tracked.files + paths.length, diff --git a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts index 4decc8a50ec3..df0dc3d79f56 100644 --- a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -1,10 +1,15 @@ import { createHash } from "crypto" +import type { BigIntStats } from "fs" import * as fs from "fs/promises" +import { LRUCache } from "lru-cache" import { binaryFile } from "../diff/shared/binary" import { resolveInside } from "../diff/shared/path" import type { GitOps } from "./GitOps" +import { Semaphore } from "./semaphore" -const MAX_BYTES = 1_000_000 +const MAX_BYTES = 1_000_000n +const reads = new Semaphore(16) +const cache = new LRUCache({ max: 10_000 }) export interface DiffStats { files: number @@ -113,6 +118,10 @@ function records(raw: Buffer): { branch: string; head: string; paths: PathState[ return { branch, head, paths, untracked } } +function stamp(stat: BigIntStats): string { + return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}` +} + async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promise { const hash = createHash("sha256").update(raw) const unique = new Map(paths.map((item) => [item.file, item])) @@ -127,7 +136,7 @@ async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promis hash.update(`\0${item.file}\0missing`) continue } - hash.update(`\0${item.file}\0${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`) + hash.update(`\0${item.file}\0${stamp(stat)}`) } return hash.digest("hex") } @@ -148,15 +157,30 @@ function numstat(raw: Buffer): DiffStats { return result } -async function lines(file: string): Promise { - const stat = await fs.lstat(file).catch(() => undefined) - if (!stat || stat.size === 0 || stat.size > MAX_BYTES) return 0 - if (await binaryFile(file)) return 0 - const content = stat.isSymbolicLink() - ? await fs.readlink(file).catch(() => "") - : await fs.readFile(file, "utf8").catch(() => "") - if (!content) return 0 - return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length +export async function lines(file: string): Promise { + return reads.run(async () => { + const stat = await fs.lstat(file, { bigint: true }).catch(() => undefined) + if (!stat) { + cache.delete(file) + return 0 + } + const key = stamp(stat) + const hit = cache.get(file) + if (hit?.stamp === key) return hit.count + + const count = await (async () => { + if (stat.size === 0n || stat.size > MAX_BYTES) return 0 + if (await binaryFile(file)) return 0 + const content = stat.isSymbolicLink() ? await fs.readlink(file) : await fs.readFile(file, "utf8") + if (!content) return 0 + return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length + })().catch(() => undefined) + if (count === undefined) return 0 + + const current = await fs.lstat(file, { bigint: true }).catch(() => undefined) + if (current && stamp(current) === key) cache.set(file, { stamp: key, count }) + return count + }) } export function refOID(refs: RefSnapshot | undefined, ref: string): string | undefined { diff --git a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts index ebe065fcca69..99e3bdcb8912 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, spyOn } from "bun:test" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" @@ -87,6 +87,89 @@ describe("GitStatsSnapshot", () => { }) }) + it("reuses unchanged untracked counts when another file changes", async () => { + await repo(async (dir, base) => { + const git = new GitOps({ log: () => undefined }) + const snapshots = new GitStatsSnapshot(git) + const files = ["stable.txt", "changing.txt"] + await Promise.all(files.map((file) => fs.writeFile(path.join(dir, file), "one\ntwo\n"))) + const read = spyOn(fs, "readFile") + try { + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 2, additions: 4, deletions: 0 }) + expect(read).toHaveBeenCalledTimes(2) + read.mockClear() + + await fs.writeFile(path.join(dir, "tracked.txt"), "one\nchanged\n") + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 3, additions: 5, deletions: 1 }) + expect(await git.workingTreeStats(dir)).toEqual({ files: 3, additions: 5, deletions: 1 }) + expect(read).not.toHaveBeenCalled() + + await fs.writeFile(path.join(dir, "changing.txt"), "12345678") + expect((await snapshots.diff(dir, base, files)).additions).toBe(4) + expect(read).toHaveBeenCalledTimes(1) + read.mockClear() + await fs.writeFile(path.join(dir, "changing.txt"), "one\ntwo\nthree\n") + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 3, additions: 6, deletions: 1 }) + expect(read).toHaveBeenCalledTimes(1) + expect(read.mock.calls.at(0)?.at(0)).toBe(path.join(dir, "changing.txt")) + + await fs.unlink(path.join(dir, "changing.txt")) + expect(await snapshots.diff(dir, base, ["stable.txt"])).toEqual({ files: 2, additions: 3, deletions: 1 }) + await fs.writeFile(path.join(dir, "changing.txt"), "replacement\n") + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 3, additions: 4, deletions: 1 }) + } finally { + read.mockRestore() + } + }) + }) + + it("does not cache failed untracked reads", async () => { + await repo(async (dir, base) => { + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + await fs.writeFile(path.join(dir, "new.txt"), "one\ntwo\n") + const read = spyOn(fs, "readFile").mockRejectedValueOnce(new Error("temporary read failure")) + try { + expect((await snapshots.diff(dir, base, ["new.txt"])).additions).toBe(0) + expect((await snapshots.diff(dir, base, ["new.txt"])).additions).toBe(2) + expect(read).toHaveBeenCalledTimes(2) + } finally { + read.mockRestore() + } + }) + }) + + it("bounds concurrent untracked file probes", async () => { + await repo(async (dir, base) => { + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + const files = Array.from({ length: 64 }, (_, i) => `${i}.txt`) + await Promise.all(files.map((file) => fs.writeFile(path.join(dir, file), "one\ntwo\n"))) + const open = fs.open + let active = 0 + let peak = 0 + const probe = spyOn(fs, "open").mockImplementation(async (...args) => { + const handle = await open(...args) + peak = Math.max(peak, ++active) + const close = handle.close.bind(handle) + handle.close = async () => { + try { + return await close() + } finally { + active-- + } + } + return handle + }) + try { + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 64, additions: 128, deletions: 0 }) + expect(peak).toBeGreaterThan(1) + expect(peak).toBeLessThanOrEqual(16) + expect(active).toBe(0) + } finally { + probe.mockRestore() + } + }) + }) + it("reads ref OIDs and upstreams", async () => { await repo(async (dir) => { run(dir, ["remote", "add", "origin", "."]) From 39be7d0523871d7fed7cbe9ac931edb4a441607a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 3 Sep 2026 11:04:22 +0200 Subject: [PATCH 2/2] fix(vscode): skip badge cache work for excluded file sizes --- .../src/agent-manager/git-stats-snapshot.ts | 4 ++-- .../tests/unit/git-stats-snapshot.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts index df0dc3d79f56..fc0980d14d01 100644 --- a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -119,7 +119,7 @@ function records(raw: Buffer): { branch: string; head: string; paths: PathState[ } function stamp(stat: BigIntStats): string { - return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}` + return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].join(":") } async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promise { @@ -164,12 +164,12 @@ export async function lines(file: string): Promise { cache.delete(file) return 0 } + if (stat.size === 0n || stat.size > MAX_BYTES) return 0 const key = stamp(stat) const hit = cache.get(file) if (hit?.stamp === key) return hit.count const count = await (async () => { - if (stat.size === 0n || stat.size > MAX_BYTES) return 0 if (await binaryFile(file)) return 0 const content = stat.isSymbolicLink() ? await fs.readlink(file) : await fs.readFile(file, "utf8") if (!content) return 0 diff --git a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts index 99e3bdcb8912..06c89561b500 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -138,6 +138,25 @@ describe("GitStatsSnapshot", () => { }) }) + it("skips extra metadata reads for empty and oversized files", async () => { + await repo(async (dir, base) => { + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + const files = ["empty.txt", "large.txt"] + await fs.writeFile(path.join(dir, "empty.txt"), "") + await fs.writeFile(path.join(dir, "large.txt"), Buffer.alloc(1_000_001, 0x61)) + const stat = spyOn(fs, "lstat") + try { + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 2, additions: 0, deletions: 0 }) + expect(stat).toHaveBeenCalledTimes(2) + await fs.writeFile(path.join(dir, "empty.txt"), "one\n") + await fs.writeFile(path.join(dir, "large.txt"), "two\nthree\n") + expect(await snapshots.diff(dir, base, files)).toEqual({ files: 2, additions: 3, deletions: 0 }) + } finally { + stat.mockRestore() + } + }) + }) + it("bounds concurrent untracked file probes", async () => { await repo(async (dir, base) => { const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined }))