fix(agent-manager): reduce background Git and GitHub process churn

This commit is contained in:
marius-kilocode
2026-08-26 13:29:39 +02:00
parent ed3380ea49
commit 743fafce09
14 changed files with 547 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce Agent Manager background Git and GitHub activity without adding file watchers.
+1
View File
@@ -5363,6 +5363,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// ── Worktree stats polling (sidebar diff badge) ──────────────────
private startStatsPolling(): void {
if (this.opts.disableStatsPolling) return
this.statsPoller?.stop()
this.statsGitOps?.dispose()
const git = new GitOps({ log: () => {} })
@@ -201,7 +201,7 @@ export class GitStatsPoller {
const worktrees = this.options.getWorktrees()
if (worktrees.length === 0) return
const presence = await this.probeWorktreePresence(worktrees)
const presence = await this.probeWorktreePresence(worktrees, refs)
if (generation !== this.generation) return
this.options.onWorktreePresence?.(presence)
@@ -313,12 +313,18 @@ export class GitStatsPoller {
.join("|")
}
private async probeWorktreePresence(worktrees: Worktree[]): Promise<WorktreePresenceResult> {
private async probeWorktreePresence(worktrees: Worktree[], refs?: RefSnapshot): Promise<WorktreePresenceResult> {
const root = this.options.getWorkspaceRoot()
if (!root) {
return { worktrees: [], degraded: true }
}
const paths = refs?.worktreePaths
if (paths) {
const items = await Promise.all(worktrees.map((wt) => this.presence(wt, root, paths)))
if (items.every((item) => !item.missing)) return { worktrees: items, degraded: false }
}
const tracked = await this.git.listWorktreePaths(root).catch((err) => {
this.options.log("Failed to list worktree paths:", err)
return undefined
@@ -343,6 +349,16 @@ export class GitStatsPoller {
return { worktrees: worktreeStatuses, degraded: false }
}
private async presence(wt: Worktree, root: string, paths: Map<string, string>): Promise<WorktreePresence> {
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
const exists = await fs.promises.access(abs).then(
() => true,
() => false,
)
const branch = exists ? findTrackedBranch(paths, abs) : undefined
return { worktreeId: wt.id, missing: !exists || branch === undefined, branch }
}
private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise<void> {
const root = this.options.getWorkspaceRoot()
if (!root) return
@@ -46,7 +46,7 @@ export class PRStatusPoller {
private ghAvailable: boolean | undefined
private ghProbeTime = 0
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
private cachedRepo: { owner: string; name: string; root: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
@@ -134,7 +134,7 @@ export class PRStatusPoller {
refresh(worktreeId: string): void {
if (!this.active) return
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (wt) this.prCache.delete(wt.branch)
if (wt) this.prCache.delete(this.key(wt.branch, wt.path))
void this.fetchOne(worktreeId)
}
@@ -258,8 +258,7 @@ export class PRStatusPoller {
}
const [checks, reviewers, comments] = await Promise.all([
this.fetchChecks(pr.number, wt.path),
this.fetchReviewers(pr.number, wt.path),
...this.extras(pr, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
if (this.stale(generation)) return
@@ -294,6 +293,10 @@ export class PRStatusPoller {
}
}
private extras(pr: PRResult, cwd: string) {
return [pr.checks ?? this.fetchChecks(pr.number, cwd), pr.reviewers ?? this.fetchReviewers(pr.number, cwd)] as const
}
private handleError(worktreeId: string, branch: string, cwd: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err)
const kind = existsSync(cwd) ? classifyPRError(msg) : "unknown"
@@ -313,20 +316,26 @@ export class PRStatusPoller {
return worktree
}
private static readonly PR_JSON_FIELDS =
private static readonly BASE_JSON_FIELDS =
"number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews`
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
* the cache, and a branch switch in a worktree naturally misses. */
private async cachedFetchPR(branch: string, cwd: string): Promise<PRResult | null> {
const cached = this.prCache.get(branch)
const key = this.key(branch, cwd)
const cached = this.prCache.get(key)
if (cached && Date.now() < cached.expires) return cached.result
const result = await this.fetchPRForBranch(branch, cwd)
this.prCache.set(branch, { result, expires: Date.now() + PR_LOOKUP_TTL })
this.prCache.set(key, { result, expires: Date.now() + PR_LOOKUP_TTL })
return result
}
private key(branch: string, cwd: string): string {
return `${this.options.getWorkspaceRoot() ?? cwd}\0${branch === "HEAD" ? cwd : branch}`
}
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
@@ -340,10 +349,7 @@ export class PRStatusPoller {
try {
const args = ["pr", "view"]
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
const { stdout } = await this.gh(args, { cwd, timeout: 15_000 })
return parsePRResult(stdout)
return parsePRResult(await this.query(args, cwd))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
@@ -351,6 +357,16 @@ export class PRStatusPoller {
}
}
private async query(args: string[], cwd: string): Promise<string> {
try {
return (await this.gh([...args, "--json", PRStatusPoller.PR_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!/unknown.*field|does(?:n't| not) exist|not accessible|insufficient|forbidden/i.test(msg)) throw err
return (await this.gh([...args, "--json", PRStatusPoller.BASE_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
}
}
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
try {
@@ -358,20 +374,9 @@ export class PRStatusPoller {
const head = sha.trim()
if (!head) return null
const { stdout } = await this.gh(
[
"pr",
"list",
"--state",
"all",
"--search",
`${head} is:pr`,
"--limit",
"5",
"--json",
PRStatusPoller.PR_JSON_FIELDS,
],
{ cwd, timeout: 15_000 },
const stdout = await this.query(
["pr", "list", "--state", "all", "--search", `${head} is:pr`, "--limit", "5"],
cwd,
)
const items = JSON.parse(stdout) as unknown[]
if (!Array.isArray(items) || items.length === 0) return null
@@ -433,15 +438,14 @@ export class PRStatusPoller {
}
private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
return this.cachedRepo
}
const root = this.options.getWorkspaceRoot() ?? cwd
if (this.cachedRepo?.root === root) return this.cachedRepo
const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], {
cwd,
timeout: 10_000,
})
const data = JSON.parse(stdout)
const info = { owner: data.owner.login as string, name: data.name as string, cwd }
const info = { owner: data.owner.login as string, name: data.name as string, root }
this.cachedRepo = info
return info
}
@@ -23,6 +23,7 @@ export interface StatusSnapshot {
export interface RefSnapshot {
oids: Map<string, string>
upstreams: Map<string, string>
worktreePaths?: Map<string, string>
}
export interface GitStatsSource {
@@ -46,6 +47,27 @@ function tail(record: string, fields: number): string | undefined {
return record.slice(offset)
}
function parse(raw: Buffer, linked: boolean): RefSnapshot {
const fields = raw.toString("utf8").split("\0")
const oids = new Map<string, string>()
const upstreams = new Map<string, string>()
const paths = linked ? new Map<string, string>() : undefined
const size = linked ? 4 : 3
for (let i = 0; i + size - 1 < fields.length; i += size) {
const ref = fields[i]?.replace(/^\n/, "")
const oid = fields[i + 1]
const upstream = fields[i + 2]
const worktree = linked ? fields[i + 3] : undefined
if (!ref || !oid) continue
oids.set(ref, oid)
if (upstream) upstreams.set(ref, upstream)
if (worktree && ref.startsWith("refs/heads/")) paths?.set(worktree, ref.slice(11))
}
return { oids, upstreams, ...(paths ? { worktreePaths: paths } : {}) }
}
function records(raw: Buffer): { branch: string; head: string; paths: PathState[]; untracked: string[] } {
const items = raw.toString("utf8").split("\0")
const paths: PathState[] = []
@@ -150,6 +172,8 @@ export function shortRef(ref: string): string {
}
export class GitStatsSnapshot implements GitStatsSource {
private supported: boolean | undefined
constructor(private readonly git: GitOps) {}
async status(dir: string): Promise<StatusSnapshot> {
@@ -181,21 +205,30 @@ export class GitStatsSnapshot implements GitStatsSource {
}
async refs(root: string): Promise<RefSnapshot> {
if (this.supported !== false) {
const result = await this.git.execGitBuffer(
[
"for-each-ref",
"--format=%(refname)%00%(objectname)%00%(upstream)%00%(worktreepath)%00",
"refs/heads",
"refs/remotes",
],
root,
)
if (result.code === 0) {
this.supported = true
return parse(result.stdout, true)
}
const error = result.stderr.toLowerCase()
if (error.includes("unknown field") || error.includes("unknown atom")) this.supported = false
}
const result = await this.git.execGitBuffer(
["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)%00", "refs/heads", "refs/remotes"],
root,
)
if (result.code !== 0) throw new Error(result.stderr.trim() || "git for-each-ref failed")
const oids = new Map<string, string>()
const upstreams = new Map<string, string>()
for (const line of result.stdout.toString("utf8").split("\n")) {
if (!line) continue
const [ref, oid, upstream] = line.split("\0")
if (!ref || !oid) continue
oids.set(ref, oid)
if (upstream) upstreams.set(ref, upstream)
}
return { oids, upstreams }
return parse(result.stdout, false)
}
async diff(dir: string, base: string, untracked: string[]): Promise<DiffStats> {
@@ -1,4 +1,4 @@
import type { PRState, ReviewDecision } from "../types"
import type { PRState, PRStatus, ReviewDecision } from "../types"
// Raw shapes returned by `gh pr view --json`
@@ -25,6 +25,8 @@ export interface GhThread {
}
export interface GhReviewRequest {
requestedReviewer?: GhAuthor
login?: string
avatarUrl?: string
}
export interface GhReview {
author?: GhAuthor
@@ -41,4 +43,6 @@ export interface PRResult {
additions: number
deletions: number
files: number
checks?: PRStatus["checks"]
reviewers?: PRStatus["reviewers"]
}
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"
import type { CheckStatus, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
import type { CheckStatus, PRCheck, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
export function parsePRResult(json: string): PRResult | null {
@@ -15,7 +15,7 @@ export function parsePRResult(json: string): PRResult | null {
: decision === "REVIEW_REQUIRED"
? "pending"
: null
return {
const result: PRResult = {
number: data.number,
title: data.title ?? "",
body: data.body ?? "",
@@ -26,6 +26,40 @@ export function parsePRResult(json: string): PRResult | null {
deletions: data.deletions ?? 0,
files: data.changedFiles ?? 0,
}
if (Array.isArray(data.statusCheckRollup)) result.checks = checks(data.statusCheckRollup)
if (Array.isArray(data.reviewRequests) && Array.isArray(data.reviews)) {
result.reviewers = parseReviewers(data.reviewRequests as GhReviewRequest[], data.reviews as GhReview[])
}
return result
}
function checks(items: unknown[]): PRStatus["checks"] {
const values = items.map((item): PRCheck => {
const check = item as {
name?: string
context?: string
state?: string
status?: string
conclusion?: string | null
link?: string
detailsUrl?: string
targetUrl?: string
startedAt?: string
completedAt?: string
}
return {
name: check.name ?? check.context ?? "Unknown check",
status: checkStatus(check.conclusion ?? check.state ?? check.status ?? "PENDING"),
url: check.detailsUrl ?? check.targetUrl ?? check.link,
duration: formatCheckDuration(check.startedAt, check.completedAt),
}
})
const total = values.filter((item) => item.status !== "skipped").length
const passed = values.filter((item) => item.status === "success").length
const failed = values.filter((item) => item.status === "failure" || item.status === "cancelled").length
const pending = values.filter((item) => item.status === "pending").length
const status = total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
return { status, total, passed, failed, pending, checks: values }
}
export function checkStatus(state: string): CheckStatus {
@@ -100,14 +134,14 @@ export function parseComments(threads: GhThread[]): PRComment[] {
export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] {
const map = new Map<string, PRReviewer>()
for (const node of requests) {
const user = node.requestedReviewer
const user = node.requestedReviewer ?? node
if (!user?.login) continue
map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" })
}
for (const node of reviews) {
const login = node.author?.login
if (!login) continue
const state = REVIEWER_STATE[node.state ?? ""] ?? "pending"
const state = REVIEWER_STATE[node.state ?? ""]
if (!login || !state) continue
if (!map.has(login) || state !== "commented") {
map.set(login, { login, avatar: node.author?.avatarUrl, state })
}
@@ -110,6 +110,7 @@ export class VscodeHost implements Host {
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
rootDirectory: opts.workspaceRoot,
disableViewedRegistration: true,
disableStatsPolling: true,
focusTargetContext: {
prompt: "kilo-code.new.agentManagerPromptFocused",
mainTerminal: "kilo-code.new.agentManagerMainTerminalFocused",
@@ -26,6 +26,7 @@ export type KiloProviderOptions = {
rootDirectory?: () => string | undefined
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
disableViewedRegistration?: boolean
disableStatsPolling?: boolean
/**
* Project route registry shared by all Agent Manager panels. When set, the
* provider resolves project-qualified session refs to exact directories and
@@ -6,6 +6,7 @@ const unresolveComment = mock(async (_threadId: string, _cwd: string) => {})
mock.module("../../src/agent-manager/pr/PRActions", () => ({ resolveComment, unresolveComment }))
import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge"
import { PRStatusPoller } from "../../src/agent-manager/PRStatusPoller"
import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types"
const pr: PRStatus = {
@@ -45,6 +46,85 @@ function harness(opts: { hasPersisted?: boolean; projectId?: string } = {}) {
return { bridge, sent, opened, onStatus, worktrees, reads }
}
describe("PRStatusPoller batched GitHub queries", () => {
it("loads checks and reviewers with one request and isolates projects and detached worktrees", async () => {
let root = "/alpha"
const tree = { id: "wt1", path: "/alpha/feature", branch: "feature" }
const calls: string[][] = []
const values: PRStatus[] = []
const poller = new PRStatusPoller({
getWorktrees: () => [tree] as never,
getWorkspaceRoot: () => root,
onStatus: (_id, status) => {
if (status) values.push(status)
},
log: () => undefined,
})
const internal = poller as unknown as {
fetchOne: (id: string) => Promise<void>
target: (id: string) => typeof tree
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
}
internal.target = () => tree
internal.gh = async (args) => {
calls.push(args)
return {
stdout: JSON.stringify({
number: root === "/alpha" ? 1 : tree.branch === "HEAD" ? (tree.path.endsWith("one") ? 3 : 4) : 2,
url: `https://github.com/example/${root.slice(1)}/pull/1`,
statusCheckRollup: [{ name: "build", conclusion: "SUCCESS" }],
reviewRequests: [{ login: "reviewer" }],
reviews: [],
}),
stderr: "",
}
}
await internal.fetchOne("wt1")
root = "/beta"
tree.path = "/beta/feature"
await internal.fetchOne("wt1")
tree.branch = "HEAD"
tree.path = "/beta/one"
await internal.fetchOne("wt1")
tree.path = "/beta/two"
await internal.fetchOne("wt1")
expect(calls).toHaveLength(4)
expect(calls.every((args) => args[0] === "pr" && args[1] === "view")).toBe(true)
expect(calls[0]?.at(-1)).toContain("statusCheckRollup,reviewRequests,reviews")
expect(values.map((item) => item.number)).toEqual([1, 2, 3, 4])
expect(values[0]?.checks.passed).toBe(1)
expect(values[0]?.reviewers).toEqual([{ login: "reviewer", avatar: undefined, state: "pending" }])
})
it.each([['Unknown JSON field: "statusCheckRollup"'], ["GraphQL: Resource not accessible by integration"]])(
"retries basic pull request fields after %s",
async (message) => {
const poller = new PRStatusPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => "/repo",
onStatus: () => undefined,
log: () => undefined,
})
const calls: string[][] = []
const internal = poller as unknown as {
query: (args: string[], cwd: string) => Promise<string>
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
}
internal.gh = async (args) => {
calls.push(args)
if (args.at(-1)?.includes("statusCheckRollup")) throw new Error(message)
return { stdout: '{"number":1}', stderr: "" }
}
expect(await internal.query(["pr", "view"], "/repo")).toBe('{"number":1}')
expect(calls).toHaveLength(2)
expect(calls[1]?.at(-1)).not.toContain("statusCheckRollup")
},
)
})
describe("PRStatusBridge.handleMessage openPR", () => {
it("opens an explicit URL from a background project", () => {
const { bridge, opened } = harness({ projectId: "active" })
@@ -161,6 +161,77 @@ describe("parsePRResult", () => {
expect(result).toEqual(
expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }),
)
expect(result).not.toHaveProperty("checks")
expect(result).not.toHaveProperty("reviewers")
})
it("parses check runs and status contexts from the pull request response", () => {
const result = parsePRResult(
JSON.stringify({
number: 7,
statusCheckRollup: [
{
name: "build",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://example.com/build",
startedAt: "2024-01-01T00:00:00Z",
completedAt: "2024-01-01T00:01:00Z",
},
{ context: "lint", state: "PENDING", targetUrl: "https://example.com/lint" },
{ name: "tests", conclusion: "FAILURE" },
{ name: "docs", conclusion: "SKIPPED" },
],
}),
)
expect(result?.checks).toEqual({
status: "failure",
total: 3,
passed: 1,
failed: 1,
pending: 1,
checks: [
{ name: "build", status: "success", url: "https://example.com/build", duration: "1m 0s" },
{ name: "lint", status: "pending", url: "https://example.com/lint", duration: undefined },
{ name: "tests", status: "failure", url: undefined, duration: undefined },
{ name: "docs", status: "skipped", url: undefined, duration: undefined },
],
})
})
it("does not mark cancelled checks as successful", () => {
const result = parsePRResult(
JSON.stringify({ number: 10, statusCheckRollup: [{ name: "build", conclusion: "CANCELLED" }] }),
)
expect(result?.checks?.status).toBe("failure")
expect(result?.checks?.failed).toBe(1)
})
it("preserves reviewer history and ignores dismissed reviews", () => {
const result = parsePRResult(
JSON.stringify({
number: 8,
reviewRequests: [{ login: "alice", avatarUrl: "https://example.com/alice" }],
reviews: [
{ author: { login: "bob" }, state: "APPROVED" },
{ author: { login: "bob" }, state: "COMMENTED" },
{ author: { login: "dismissed" }, state: "DISMISSED" },
],
}),
)
expect(result?.reviewers).toEqual([
{ login: "alice", avatar: "https://example.com/alice", state: "pending" },
{ login: "bob", avatar: undefined, state: "approved" },
])
})
it("keeps empty rich fields so legacy follow-up requests are unnecessary", () => {
const result = parsePRResult(JSON.stringify({ number: 9, statusCheckRollup: [], reviewRequests: [], reviews: [] }))
expect(result?.checks).toEqual({ status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] })
expect(result?.reviewers).toEqual([])
})
})
@@ -9,6 +9,23 @@ import { Semaphore } from "../../src/agent-manager/semaphore"
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
import type { WorktreeDiffEntry } from "../../src/agent-manager/types"
function run(dir: string, args: string[]): void {
const result = Bun.spawnSync({
cmd: ["git", ...args],
cwd: dir,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
},
})
if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8"))
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -78,6 +95,7 @@ function source(
class RecordingGitOps extends GitOps {
readonly commands: Array<{ args: string[]; cwd: string }> = []
worktreeCalls = 0
aheadCalls = 0
constructor() {
@@ -89,6 +107,11 @@ class RecordingGitOps extends GitOps {
return super.execGitBuffer(args, cwd)
}
override listWorktreePaths(cwd: string): Promise<Map<string, string>> {
this.worktreeCalls++
return super.listWorktreePaths(cwd)
}
override aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> {
this.aheadCalls++
return super.aheadBehind(cwd, base)
@@ -125,6 +148,132 @@ describe("GitOps", () => {
})
describe("GitStatsPoller", () => {
it("uses isolated ref worktree maps for linked worktrees in two repositories", async () => {
const roots = await Promise.all([
fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-isolated-one-")),
fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-isolated-two-")),
])
const linked = roots.map((root) => path.join(root, "linked"))
try {
for (const [index, root] of roots.entries()) {
run(root, ["init", "-b", "main"])
run(root, ["config", "commit.gpgsign", "false"])
await fs.promises.writeFile(path.join(root, "file.txt"), `${index}\n`)
run(root, ["add", "."])
run(root, ["commit", "-m", "base"])
run(root, ["remote", "add", "origin", "."])
run(root, ["update-ref", "refs/remotes/origin/main", "HEAD"])
run(root, ["branch", "--set-upstream-to=origin/main", "main"])
run(root, ["worktree", "add", "-b", "feature", linked[index]!, "main"])
}
const recorders = roots.map(() => new RecordingGitOps())
const pollers = roots.map(
(root, index) =>
new GitStatsPoller({
getWorktrees: () => [{ ...worktree(`wt-${index}`), branch: "feature", path: linked[index]! }],
getWorkspaceRoot: () => root,
onStats: () => undefined,
onLocalStats: () => undefined,
log: () => undefined,
intervalMs: 500,
git: recorders[index]!,
}),
)
pollers.forEach((poller) => poller.setEnabled(true))
await Promise.all(recorders.map((git) => waitFor(() => git.aheadCalls >= 2, 2_000)))
pollers.forEach((poller) => poller.stop())
for (const [index, git] of recorders.entries()) {
expect(git.worktreeCalls).toBe(0)
expect(git.aheadCalls).toBe(2)
expect(git.commands.some((item) => item.args[1]?.includes("%(worktreepath)"))).toBe(true)
expect(git.commands.some((item) => item.cwd === roots[index])).toBe(true)
}
} finally {
await Promise.all(roots.map((root) => fs.promises.rm(root, { recursive: true, force: true })))
}
})
it("falls back to worktree listing and aheadBehind for detached worktrees", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-fallback-"))
const named = path.join(root, "named")
const detached = path.join(root, "detached")
try {
run(root, ["init", "-b", "main"])
run(root, ["config", "commit.gpgsign", "false"])
await fs.promises.writeFile(path.join(root, "file.txt"), "base\n")
run(root, ["add", "."])
run(root, ["commit", "-m", "base"])
run(root, ["remote", "add", "origin", "."])
run(root, ["update-ref", "refs/remotes/origin/main", "HEAD"])
run(root, ["worktree", "add", "-b", "feature", named, "main"])
run(root, ["worktree", "add", "--detach", detached, "main"])
const git = new RecordingGitOps()
const poller = new GitStatsPoller({
getWorktrees: () => [
{ ...worktree("named"), branch: "feature", path: named },
{ ...worktree("detached"), branch: "HEAD", path: detached },
],
getWorkspaceRoot: () => root,
onStats: () => undefined,
onLocalStats: () => undefined,
log: () => undefined,
intervalMs: 500,
git,
})
poller.setEnabled(true)
await waitFor(() => git.worktreeCalls >= 1 && git.aheadCalls >= 2, 2_000)
poller.stop()
expect(git.commands.filter((item) => item.args[0] === "for-each-ref")).toHaveLength(1)
expect(git.worktreeCalls).toBe(1)
expect(git.aheadCalls).toBeGreaterThan(0)
} finally {
await fs.promises.rm(root, { recursive: true, force: true })
}
})
it("falls back to worktree listing when ref metadata is incomplete", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-incomplete-"))
const named = path.join(root, "named")
try {
run(root, ["init", "-b", "main"])
run(root, ["config", "commit.gpgsign", "false"])
await fs.promises.writeFile(path.join(root, "file.txt"), "base\n")
run(root, ["add", "."])
run(root, ["commit", "-m", "base"])
run(root, ["worktree", "add", "-b", "feature", named, "main"])
const git = new RecordingGitOps()
const poller = new GitStatsPoller({
getWorktrees: () => [{ ...worktree("named"), branch: "feature", path: named }],
getWorkspaceRoot: () => root,
source: {
status: async () => ({ branch: "feature", dirty: false, head: "head", fingerprint: "stamp", untracked: [] }),
refs: async () => ({ oids: new Map([["refs/heads/feature", "head"]]), upstreams: new Map() }),
diff: async () => ({ files: 0, additions: 0, deletions: 0 }),
},
onStats: () => undefined,
onLocalStats: () => undefined,
log: () => undefined,
intervalMs: 500,
git,
})
poller.setEnabled(true)
await waitFor(() => git.worktreeCalls >= 1, 2_000)
poller.stop()
expect(git.worktreeCalls).toBe(1)
} finally {
await fs.promises.rm(root, { recursive: true, force: true })
}
})
it("uses only status and shared snapshots on an unchanged second poll", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-optimized-"))
try {
@@ -96,6 +96,64 @@ describe("GitStatsSnapshot", () => {
const refs = await snapshots.refs(dir)
expect(refOID(refs, "origin/main")).toBe(run(dir, ["rev-parse", "HEAD"]))
expect(refs.upstreams.get("refs/heads/main")).toBe("refs/remotes/origin/main")
expect(refs.worktreePaths?.get(await fs.realpath(dir))).toBe("main")
})
})
it("keeps linked worktree maps isolated for repositories with the same branch names", async () => {
const roots = await Promise.all([
fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-one-")),
fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-two-")),
])
const worktrees = roots.map((root) => path.join(root, "linked"))
try {
for (const [index, root] of roots.entries()) {
run(root, ["init", "-b", "main"])
run(root, ["config", "commit.gpgsign", "false"])
await fs.writeFile(path.join(root, "tracked.txt"), `${index}\n`)
run(root, ["add", "."])
run(root, ["commit", "-m", "base"])
run(root, ["worktree", "add", "-b", "feature", worktrees[index]!, "main"])
}
const snapshots = roots.map(() => new GitStatsSnapshot(new GitOps({ log: () => undefined })))
const refs = await Promise.all(roots.map((root, index) => snapshots[index]!.refs(root)))
const paths = await Promise.all(worktrees.map((worktree) => fs.realpath(worktree)))
expect(refs[0]!.worktreePaths?.get(paths[0]!)).toBe("feature")
expect(refs[0]!.worktreePaths?.has(paths[1]!)).toBe(false)
expect(refs[1]!.worktreePaths?.get(paths[1]!)).toBe("feature")
expect(refs[1]!.worktreePaths?.has(paths[0]!)).toBe(false)
} finally {
await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true })))
}
})
it("parses a linked worktree path containing a newline", async () => {
await repo(async (dir) => {
const worktree = path.join(dir, "linked\nworktree")
run(dir, ["worktree", "add", "-b", "feature", worktree, "main"])
const refs = await new GitStatsSnapshot(new GitOps({ log: () => undefined })).refs(dir)
expect(refs.worktreePaths?.get(await fs.realpath(worktree))).toBe("feature")
})
})
it("falls back to the plain ref query when worktreepath is unsupported", async () => {
const calls: string[][] = []
const git = new GitOps({ log: () => undefined })
git.execGitBuffer = async (args): Promise<ExecBufferResult> => {
calls.push(args)
if (args[1]?.includes("%(worktreepath)")) {
return { code: 1, stdout: Buffer.alloc(0), stderr: "unknown field name: worktreepath" }
}
return { code: 0, stdout: Buffer.from("refs/heads/main\0abc\0\0\0"), stderr: "" }
}
const refs = await new GitStatsSnapshot(git).refs("/repo")
expect(refs.oids.get("refs/heads/main")).toBe("abc")
expect(refs.worktreePaths).toBeUndefined()
expect(calls).toHaveLength(2)
expect(calls[0]![1]).toContain("%(worktreepath)")
expect(calls[1]![1]).not.toContain("%(worktreepath)")
})
})
@@ -15,6 +15,7 @@ type Internals = {
handleEvent: (event: Event, directory?: string) => void
refreshGitStatus: (directory?: string) => Promise<void>
refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise<boolean>
resolveGitRoot: (directory: string) => Promise<string | undefined>
initializeConnection: () => Promise<void>
syncWebviewState: () => Promise<void>
flushPendingSessionRefresh: () => Promise<void>
@@ -27,6 +28,7 @@ type Internals = {
seedSessionStatusMap: () => Promise<void>
sendNotificationSettings: () => void
startStatsPolling: () => void
statsPoller: { stop: () => void } | null
}
function created(input: { id: string; directory: string; parentID?: string }): Event {
@@ -86,6 +88,8 @@ function connection() {
onFavoritesChanged: () => () => undefined,
onModelSelectorExpandedChanged: () => () => undefined,
registerDirectoryProvider: () => () => undefined,
unregisterVisible: () => undefined,
unregisterAttached: () => undefined,
getServerInfo: () => ({ port: 12345 }),
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
getConnectionState: () => "connected" as const,
@@ -96,6 +100,12 @@ function connection() {
}
}
function git() {
const service = connection()
const client = { project: { current: async () => ({ data: { vcs: "git" } }) } }
return { ...service, getClient: () => client as never }
}
describe("KiloProvider follow-up sessions", () => {
it("scopes shared session events to the active project directory", () => {
const service = connection()
@@ -199,6 +209,39 @@ describe("KiloProvider follow-up sessions", () => {
expect(dirs).toEqual(["/workspace/frontend/src"])
})
it("starts standalone stats polling and skips it for embedded providers", async () => {
const standalone = new KiloProvider({} as never, connection() as never)
const normal = standalone as unknown as Internals
normal.startStatsPolling()
expect(normal.statsPoller).not.toBeNull()
standalone.dispose()
const embedded = new KiloProvider({} as never, git() as never, undefined, {
disableStatsPolling: true,
})
const internal = embedded as unknown as Internals
const sent: unknown[] = []
let active = "a"
internal.webview = {
postMessage: async (message: unknown) => {
sent.push(message)
return true
},
}
internal.resolveGitRoot = async () => undefined
await internal.refreshGitStatus(`/repo/${active}`)
active = "b"
await internal.refreshGitStatus(`/repo/${active}`)
expect(internal.statsPoller).toBeNull()
expect(sent).toEqual([
{ type: "gitStatus", repo: true },
{ type: "gitStatus", repo: true },
])
embedded.dispose()
})
it("ignores completed tool paths outside the active project", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {