mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-31 01:37:28 +08:00
fix(vscode): avoid macOS git launcher overhead
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user