diff --git a/.changeset/quiet-agent-manager-stats.md b/.changeset/quiet-agent-manager-stats.md new file mode 100644 index 0000000000..edf9050895 --- /dev/null +++ b/.changeset/quiet-agent-manager-stats.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager background Git and GitHub activity without adding file watchers. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 16afe7d380..db5c717dca 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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: () => {} }) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 11e513f32c..2ef81acefa 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -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 { + private async probeWorktreePresence(worktrees: Worktree[], refs?: RefSnapshot): Promise { 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): Promise { + 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 { const root = this.options.getWorkspaceRoot() if (!root) return diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index e71611b350..75ce27c5a4 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -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() 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 { - 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 { // 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 { + 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 { 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 } 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 bfe2f02b8e..4decc8a50e 100644 --- a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -23,6 +23,7 @@ export interface StatusSnapshot { export interface RefSnapshot { oids: Map upstreams: Map + worktreePaths?: Map } 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() + const upstreams = new Map() + const paths = linked ? new Map() : 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 { @@ -181,21 +205,30 @@ export class GitStatsSnapshot implements GitStatsSource { } async refs(root: string): Promise { + 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() - const upstreams = new Map() - 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 { diff --git a/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts index f733f57f7d..40ae15e4fc 100644 --- a/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts @@ -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"] } diff --git a/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts index 5e0de090b5..e947bcf7ad 100644 --- a/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts @@ -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() 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 }) } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 943387890d..83b307727c 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -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", diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 1bde4dc012..b9e9994d89 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -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 diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts index d9ccb6c739..66f146e67b 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -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 + 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 + 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" }) diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts index 1b56b7aa82..ea63ea9d21 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -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([]) }) }) diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index 92d94a3477..db077a904f 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -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 { 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> { + 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 { 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 ef53d5300e..ebe065fcca 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -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 => { + 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)") + }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 0b1ce8a9e5..c75c205421 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -15,6 +15,7 @@ type Internals = { handleEvent: (event: Event, directory?: string) => void refreshGitStatus: (directory?: string) => Promise refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise + resolveGitRoot: (directory: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -27,6 +28,7 @@ type Internals = { seedSessionStatusMap: () => Promise 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, {