fix(vscode): avoid macOS git launcher overhead

This commit is contained in:
marius-kilocode
2026-08-05 11:40:43 +02:00
parent 974f03203a
commit c0649f7cb2
7 changed files with 375 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce Agent Manager Git polling overhead by reusing the validated Git executable and bypassing the macOS developer-tool launcher when safe.
@@ -27,6 +27,7 @@ import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type Work
import { PRStatusBridge } from "./pr-status-bridge"
import { createPollers, type ProjectPollers } from "./project/pollers"
import { GitOps } from "./GitOps"
import type { GitExecutable } from "../util/git-executable"
import { versionedName } from "./branch-name"
import { BranchNamingController } from "./branch-naming"
import { SetupScriptService } from "./SetupScriptService"
@@ -122,6 +123,7 @@ export class AgentManagerProvider implements Disposable {
constructor(
private readonly host: Host,
private readonly connectionService: KiloConnectionService,
binary: GitExecutable = () => Promise.resolve("git"),
) {
this.outputChannel = host.createOutput("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager(
@@ -175,7 +177,7 @@ export class AgentManagerProvider implements Disposable {
log: (...args) => this.log(...args),
})
const semaphore = new Semaphore(3)
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore, binary })
const wiring = createProjectWiring({
host: this.host,
git: this.gitOps,
+103 -49
View File
@@ -2,6 +2,7 @@ import * as nodePath from "path"
import * as os from "os"
import * as fs from "fs/promises"
import { spawn } from "../util/process"
import type { GitExecutable } from "../util/git-executable"
import simpleGit from "simple-git"
import {
parseWorktreeList,
@@ -18,6 +19,8 @@ interface GitOpsOptions {
runGit?: (args: string[], cwd: string) => Promise<string>
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
/** Validated Git executable shared by Agent Manager operations. */
binary?: GitExecutable
}
export interface ApplyConflict {
@@ -96,6 +99,9 @@ export class GitOps {
private readonly runGit: (args: string[], cwd: string) => Promise<string>
private readonly controller = new AbortController()
private readonly semaphore: Semaphore | undefined
private readonly binary: GitExecutable
private readonly injected: boolean
private executableCache: Promise<string> | undefined
private readonly resolutionCache = new Map<string, { value: string; expires: number }>()
private static readonly CACHE_TTL_MS = 60000
private static readonly MAX_CACHE_SIZE = 100
@@ -107,12 +113,19 @@ export class GitOps {
constructor(options: GitOpsOptions) {
this.log = options.log
this.semaphore = options.semaphore
this.binary = options.binary ?? (() => Promise.resolve("git"))
this.injected = options.runGit !== undefined
this.runGit =
options.runGit ??
((args, cwd) =>
simpleGit(cwd, { abort: this.controller.signal })
(async (args, cwd) => {
const binary = await this.executable()
return simpleGit(cwd, {
abort: this.controller.signal,
binary,
})
.raw(args)
.then((out) => out.trim()))
.then((out) => out.trim())
})
}
dispose(): void {
@@ -148,22 +161,28 @@ export class GitOps {
private raw(args: string[], cwd: string): Promise<string> {
const signal = this.controller.signal
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
const invoke = () =>
new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
this.runGit(args, cwd).then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
return this.semaphore ? this.semaphore.run(invoke) : invoke()
return this.executable().then(() => {
if (signal.aborted) throw new Error("GitOps disposed")
const invoke = () => {
const pending = this.runGit(args, cwd)
if (!this.injected) return pending
return new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
pending.then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
}
return this.semaphore ? this.semaphore.run(invoke) : invoke()
})
}
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
@@ -551,43 +570,78 @@ export class GitOps {
return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr }
}
private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
private async execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
if (this.controller.signal.aborted) {
return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }
}
const cmd = await this.executable().catch(() => undefined)
if (!cmd || this.controller.signal.aborted) {
return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }
}
const invoke = () => this.invoke(cmd, args, cwd, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
private executable(): Promise<string> {
const signal = this.controller.signal
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
return new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
this.executableCache ??= Promise.resolve().then(() => this.binary())
this.executableCache.then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
}
private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
if (this.controller.signal.aborted) {
return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" })
}
const invoke = () =>
new Promise<ExecBufferResult>((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
signal: this.controller.signal,
stdio: ["pipe", "pipe", "pipe"],
})
if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" })
return
}
child.stdin.end(options.stdin)
}
return new Promise<ExecBufferResult>((resolve) => {
const child = spawn(cmd, args, {
cwd,
env: options?.env,
stdio: ["pipe", "pipe", "pipe"],
})
const out: Buffer[] = []
const err: Buffer[] = []
let failure: string | undefined
const abort = () => child.kill("SIGINT")
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
this.controller.signal.addEventListener("abort", abort, { once: true })
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
child.on("error", (error) => {
resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out),
stderr: Buffer.concat(err).toString("utf8"),
})
child.on("error", (error) => {
failure = error.message
})
child.on("close", (code) => {
this.controller.signal.removeEventListener("abort", abort)
resolve({
code: code ?? 1,
stdout: Buffer.concat(out),
stderr: failure ?? Buffer.concat(err).toString("utf8"),
})
})
return this.semaphore ? this.semaphore.run(invoke) : invoke()
if (options?.stdin === undefined) return
if (child.stdin) {
child.stdin.end(options.stdin)
return
}
failure = "stdin not available for git process"
child.kill("SIGINT")
})
}
}
+5 -1
View File
@@ -25,6 +25,7 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot"
import { RemoteStatusService } from "./services/RemoteStatusService"
import { markWorkspace } from "./util/spotlight"
import { createNotebookBridge } from "./services/notebook"
import { createGitExecutable } from "./util/git-executable"
let agentManager: AgentManagerProvider | undefined
let shuttingDown = false
@@ -149,7 +150,10 @@ export function activate(context: vscode.ExtensionContext) {
// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService)
const git = createGitExecutable({
log: (message) => console.warn(`[Kilo New] ${message}`),
})
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
context.subscriptions.push(agentManagerProvider)
@@ -0,0 +1,70 @@
import { constants } from "fs"
import * as fs from "fs/promises"
import * as path from "path"
import { exec } from "./process"
export type GitExecutable = () => Promise<string>
interface GitExecutableOptions {
platform?: NodeJS.Platform
path?: string
run?: (cmd: string, args: string[]) => Promise<{ stdout: string }>
access?: (file: string, mode: number) => Promise<void>
realpath?: (file: string) => Promise<string>
log?: (message: string) => void
}
/**
* Preserve normal PATH lookup on every platform. On macOS only, bypass Apple's
* /usr/bin/git launcher after confirming it is the command PATH would select and
* xcrun identifies a valid executable for the active developer directory.
*/
export function createGitExecutable(options: GitExecutableOptions = {}): GitExecutable {
const platform = options.platform ?? process.platform
const run = options.run ?? ((cmd, args) => exec(cmd, args, { timeout: 15_000 }))
const access = options.access ?? fs.access
const realpath = options.realpath ?? fs.realpath
const log = options.log ?? (() => undefined)
let cached: Promise<string> | undefined
return (): Promise<string> => {
cached ??= (async () => {
if (platform !== "darwin") return "git"
try {
const env = options.path ?? process.env.PATH ?? "/usr/bin:/bin"
const selected = await (async () => {
for (const dir of env.split(path.posix.delimiter)) {
// Relative and empty PATH entries depend on each command's cwd, so
// they cannot be resolved once without changing lookup semantics.
if (!dir || !path.posix.isAbsolute(dir)) return undefined
const file = path.posix.join(dir, "git")
const resolved = await access(file, constants.X_OK)
.then(() => realpath(file))
.catch(() => undefined)
if (resolved) return resolved
}
return undefined
})()
if (selected !== "/usr/bin/git") return "git"
const result = await run("/usr/bin/xcrun", ["--find", "git"])
const candidate = result.stdout.trim()
if (!candidate || candidate === selected || !path.posix.isAbsolute(candidate)) return "git"
if (!/^[/a-zA-Z0-9._~-]+$/.test(candidate)) return "git"
await access(candidate, constants.X_OK)
const version = await run(candidate, ["--version"])
if (!version.stdout.trim().startsWith("git version ")) return "git"
log(`Using ${candidate} directly instead of the macOS Git launcher`)
return candidate
} catch (err) {
log(`Unable to bypass the macOS Git launcher, using PATH: ${err}`)
return "git"
}
})()
return cached
}
}
@@ -0,0 +1,142 @@
import { describe, expect, it } from "bun:test"
import { createGitExecutable } from "../../src/util/git-executable"
describe("createGitExecutable", () => {
it("preserves PATH lookup on other platforms", async () => {
const git = createGitExecutable({
platform: "linux",
run: async () => {
throw new Error("should not run")
},
})
expect(await git()).toBe("git")
})
it("resolves and validates the real macOS Git executable", async () => {
const calls: string[] = []
const git = createGitExecutable({
platform: "darwin",
path: "/usr/bin:/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async (cmd, args) => {
calls.push([cmd, ...args].join(" "))
if (cmd === "/usr/bin/xcrun") return { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" }
return { stdout: "git version 2.50.1\n" }
},
})
expect(await git()).toBe("/Library/Developer/CommandLineTools/usr/bin/git")
expect(calls).toEqual(["/usr/bin/xcrun --find git", "/Library/Developer/CommandLineTools/usr/bin/git --version"])
})
it("falls back to the macOS launcher when resolution fails", async () => {
const git = createGitExecutable({
platform: "darwin",
path: "/usr/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async () => {
throw new Error("xcrun failed")
},
})
expect(await git()).toBe("git")
})
it("rejects a resolved command that is not Git", async () => {
const git = createGitExecutable({
platform: "darwin",
path: "/usr/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async (cmd) =>
cmd === "/usr/bin/xcrun" ? { stdout: "/tmp/not-git\n" } : { stdout: "unexpected command\n" },
})
expect(await git()).toBe("git")
})
it("does not override a non-Apple Git selected by PATH", async () => {
let calls = 0
const git = createGitExecutable({
platform: "darwin",
path: "/opt/homebrew/bin:/usr/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async () => {
calls++
return { stdout: "" }
},
})
expect(await git()).toBe("git")
expect(calls).toBe(0)
})
it("keeps per-command lookup for relative PATH entries", async () => {
let calls = 0
const git = createGitExecutable({
platform: "darwin",
path: "./bin:/usr/bin",
run: async () => {
calls++
return { stdout: "" }
},
})
expect(await git()).toBe("git")
expect(calls).toBe(0)
})
it("keeps per-command lookup for empty PATH entries", async () => {
let calls = 0
const git = createGitExecutable({
platform: "darwin",
path: ":/usr/bin",
run: async () => {
calls++
return { stdout: "" }
},
})
expect(await git()).toBe("git")
expect(calls).toBe(0)
})
it("keeps PATH lookup when the developer directory contains unsafe path characters", async () => {
const git = createGitExecutable({
platform: "darwin",
path: "/usr/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async () => ({ stdout: "/Applications/Xcode Beta.app/Contents/Developer/usr/bin/git\n" }),
})
expect(await git()).toBe("git")
})
it("shares one resolution across concurrent callers", async () => {
let calls = 0
const git = createGitExecutable({
platform: "darwin",
path: "/usr/bin",
access: async () => undefined,
realpath: async (file) => file,
run: async (cmd) => {
calls++
return cmd === "/usr/bin/xcrun"
? { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" }
: { stdout: "git version 2.50.1\n" }
},
})
expect(await Promise.all([git(), git(), git()])).toEqual([
"/Library/Developer/CommandLineTools/usr/bin/git",
"/Library/Developer/CommandLineTools/usr/bin/git",
"/Library/Developer/CommandLineTools/usr/bin/git",
])
expect(calls).toBe(2)
})
})
@@ -41,6 +41,53 @@ async function withRepo(run: (cwd: string) => Promise<void>): Promise<void> {
}
describe("GitOps", () => {
it("uses the configured Git executable for raw commands", async () => {
await withRepo(async (cwd) => {
let calls = 0
const git = new GitOps({
log: () => undefined,
binary: async () => {
calls++
return "git"
},
})
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
expect(calls).toBe(1)
})
})
it("does not hold a semaphore slot while resolving Git", async () => {
const semaphore = new Semaphore(1)
let resolve!: (value: string) => void
const binary = new Promise<string>((done) => {
resolve = done
})
const git = new GitOps({ log: () => undefined, semaphore, binary: () => binary })
const pending = git.currentBranch("/repo")
let entered = false
await semaphore.run(async () => {
entered = true
})
resolve("git")
await pending
expect(entered).toBe(true)
})
it("stops waiting for Git resolution when disposed", async () => {
const git = new GitOps({
log: () => undefined,
binary: () => new Promise(() => undefined),
})
const pending = git.currentBranch("/repo")
git.dispose()
expect(await pending).toBe("")
})
describe("currentBranch", () => {
it("returns the current branch name", async () => {
const git = ops(async (args) => {