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
5 changes: 5 additions & 0 deletions .changeset/quiet-badge-scans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Reduce change-badge overhead in large repositories by reusing unchanged file counts and limiting concurrent file reads.
16 changes: 2 additions & 14 deletions packages/kilo-vscode/src/agent-manager/GitOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 35 additions & 11 deletions packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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<string, { stamp: string; count: number }>({ max: 10_000 })

export interface DiffStats {
files: number
Expand Down Expand Up @@ -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].join(":")
}

async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promise<string | undefined> {
const hash = createHash("sha256").update(raw)
const unique = new Map(paths.map((item) => [item.file, item]))
Expand All @@ -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)}`)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
}
return hash.digest("hex")
}
Expand All @@ -148,15 +157,30 @@ function numstat(raw: Buffer): DiffStats {
return result
}

async function lines(file: string): Promise<number> {
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<number> {
return reads.run(async () => {
const stat = await fs.lstat(file, { bigint: true }).catch(() => undefined)
if (!stat) {
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 (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)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
if (current && stamp(current) === key) cache.set(file, { stamp: key, count })
return count
})
}

export function refOID(refs: RefSnapshot | undefined, ref: string): string | undefined {
Expand Down
104 changes: 103 additions & 1 deletion packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -87,6 +87,108 @@ 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("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 }))
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", "."])
Expand Down
Loading