Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-windows-worktree-git.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Use the Git executable configured in VS Code when creating worktrees on Windows.
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class AgentManagerProvider implements Disposable {
constructor(
private readonly host: Host,
private readonly connectionService: KiloConnectionService,
binary: GitExecutable = () => Promise.resolve("git"),
binary: GitExecutable | string = "git",
) {
this.outputChannel = host.createOutput("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager(
Expand Down Expand Up @@ -1800,10 +1800,10 @@ export class AgentManagerProvider implements Disposable {

this.openPanel()
await this.waitForStateReady("continueFromSidebar")

await continueInWorktree(
{
root,
binary: this.gitOps.path,
getClient: () => this.connectionService.getClient(),
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
Expand Down
12 changes: 10 additions & 2 deletions packages/kilo-vscode/src/agent-manager/GitOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface GitOpsOptions {
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
/** Validated Git executable shared by Agent Manager operations. */
binary?: GitExecutable
binary?: GitExecutable | string
}

export interface ApplyConflict {
Expand Down Expand Up @@ -131,14 +131,21 @@ export class GitOps {
private static readonly DEFAULT_BRANCH_CACHE_TTL_MS = 10 * 60_000
private static readonly MAX_CACHE_SIZE = 100

public readonly path: string

get disposed(): boolean {
return this.controller.signal.aborted
}

constructor(options: GitOpsOptions) {
this.log = options.log
this.semaphore = options.semaphore
this.binary = options.binary ?? (() => Promise.resolve("git"))
const configured = options.binary
this.path = typeof configured === "string" ? configured : "git"
this.binary =
typeof configured === "string"
? () => Promise.resolve(configured)
: (configured ?? (() => Promise.resolve("git")))
this.injected = options.runGit !== undefined
this.runGit =
options.runGit ??
Expand All @@ -147,6 +154,7 @@ export class GitOps {
return simpleGit(cwd, {
abort: this.controller.signal,
binary,
unsafe: { allowUnsafeCustomBinary: binary !== "git" },
})
.raw(args)
.then((out) => out.trim())
Expand Down
46 changes: 29 additions & 17 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,16 @@ export class WorktreeManager {
private readonly dir: string
private readonly git: SimpleGit
private readonly ops: GitOps | undefined
private readonly binary: string
private readonly log: (msg: string) => void
private migrated = false

constructor(root: string, log: (msg: string) => void, ops?: GitOps) {
constructor(root: string, log: (msg: string) => void, ops?: GitOps, binary?: string) {
this.root = root
this.dir = path.join(root, KILO_DIR, "worktrees")
this.git = simpleGit(root)
this.ops = ops
this.binary = binary ?? ops?.path ?? "git"
this.git = this.client(root)
this.log = log
}

Expand All @@ -121,8 +123,8 @@ export class WorktreeManager {
// Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done
private static fetchCache = new Map<string, number>()
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
private static gitAvailable = false
private static lfsAvailable: boolean | undefined
private gitAvailable = false
private lfsAvailable: boolean | undefined

private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
const key = this.root
Expand All @@ -136,6 +138,16 @@ export class WorktreeManager {
return result
}

private client(cwd: string, ssh = false): SimpleGit {
return simpleGit(cwd, {
binary: this.binary,
unsafe: {
allowUnsafeCustomBinary: this.binary !== "git",
allowUnsafeSshCommand: ssh,
},
})
}

// ---------------------------------------------------------------------------
// Public API (acquires git lock)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -169,7 +181,7 @@ export class WorktreeManager {
async hasWork(worktreePath: string, base: string): Promise<boolean> {
if (!this.isManagedPath(worktreePath)) return false
return this.withGitLock(async () => {
const git = simpleGit(worktreePath)
const git = this.client(worktreePath)
const status = await git.status()
if (status.files.length > 0) return true
return git
Expand All @@ -185,12 +197,12 @@ export class WorktreeManager {
}

private async ensureGitAvailable(): Promise<void> {
if (WorktreeManager.gitAvailable) return
if (this.gitAvailable) return
try {
await execWithShellEnv("git", ["--version"])
WorktreeManager.gitAvailable = true
await execWithShellEnv(this.binary, ["--version"])
this.gitAvailable = true
} catch (error) {
WorktreeManager.gitAvailable = false
this.gitAvailable = false
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(
"Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.",
Expand Down Expand Up @@ -313,7 +325,7 @@ export class WorktreeManager {
private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise<string> {
if (!this.isManagedPath(worktreePath)) throw new Error("Worktree is not managed by Agent Manager")

const git = simpleGit(worktreePath)
const git = this.client(worktreePath)
const actual = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
if (actual === "HEAD" || actual !== current) throw new Error("Branch changed before automatic naming")

Expand Down Expand Up @@ -721,7 +733,7 @@ export class WorktreeManager {
}

try {
const git = simpleGit(wtPath)
const git = this.client(wtPath)
const [branch, stat, meta] = await Promise.all([
git.revparse(["--abbrev-ref", "HEAD"]),
fs.promises.stat(wtPath),
Expand Down Expand Up @@ -855,7 +867,7 @@ export class WorktreeManager {
// is the fixed value Kilo injects — never for an inherited one, which
// could be attacker-controlled.
const env = nonInteractiveEnv()
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
await this.client(this.root, isKiloOwnedSshCommand(env))
.env(env)
.raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`])
WorktreeManager.fetchCache.set(key, Date.now())
Expand Down Expand Up @@ -925,13 +937,13 @@ export class WorktreeManager {
}

async checkLfsAvailable(): Promise<boolean> {
if (WorktreeManager.lfsAvailable) return true
if (this.lfsAvailable) return true
try {
await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 })
WorktreeManager.lfsAvailable = true
await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: 5000 })
this.lfsAvailable = true
return true
} catch {
WorktreeManager.lfsAvailable = false
this.lfsAvailable = false
// git-lfs not installed
return false
}
Expand Down Expand Up @@ -1165,7 +1177,7 @@ export class WorktreeManager {
}

private async gitExec(args: string[]): Promise<void> {
await this.exec("git", args)
await this.exec(this.binary, args)
}

private async gitTry(args: string[]): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { recordForkHandoff } from "./fork-handoff"

export interface ContinueContext {
root: string
binary?: string
getClient: () => KiloClient
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
worktree: { id: string }
Expand Down Expand Up @@ -42,7 +43,7 @@ export async function abortSession(ctx: ContinueContext, sessionId: string): Pro
/** Capture git state from the workspace root. */
export async function captureState(ctx: ContinueContext): Promise<StepResult<GitSnapshot>> {
try {
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args))
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args), ctx.binary)
return { ok: true, value: snapshot }
} catch (err) {
return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` }
Expand All @@ -67,7 +68,7 @@ export async function transferState(
snapshot: GitSnapshot,
target: string,
): Promise<StepResult<void>> {
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args))
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args), ctx.binary)
if (!applied.ok) {
ctx.log("Git state transfer failed:", applied.error)
return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" }
Expand Down
32 changes: 19 additions & 13 deletions packages/kilo-vscode/src/agent-manager/git-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ export interface UntrackedFile {

const MAX_FILE = 10 * 1024 * 1024 // 10 MB

function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> {
function git(
args: string[],
cwd: string,
stdin?: string,
binary = "git",
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
if (stdin !== undefined) {
// Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe
const child = cp.spawn("git", args, { cwd, windowsHide: true })
const child = cp.spawn(binary, args, { cwd, windowsHide: true })
let stdout = ""
let stderr = ""
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()))
Expand All @@ -42,7 +47,7 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
child.stdin.end(stdin)
} else {
cp.execFile(
"git",
binary,
args,
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
(error, stdout, stderr) => {
Expand All @@ -58,28 +63,28 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
})
}

async function raw(args: string[], cwd: string): Promise<string> {
const result = await git(args, cwd)
async function raw(args: string[], cwd: string, binary = "git"): Promise<string> {
const result = await git(args, cwd, undefined, binary)
return result.stdout.trim()
}

/**
* Capture the current git state from `cwd` as a portable snapshot.
* This is a read-only operation — the source directory is never modified.
*/
export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise<GitSnapshot> {
export async function capture(cwd: string, log: (...args: unknown[]) => void, binary = "git"): Promise<GitSnapshot> {
const patch = (args: string[]) =>
git(args, cwd).then((r) => {
git(args, cwd, undefined, binary).then((r) => {
const out = r.stdout
return out.trim() ? out : null
})

const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([
raw(["branch", "--show-current"], cwd),
raw(["rev-parse", "HEAD"], cwd),
raw(["branch", "--show-current"], cwd, binary),
raw(["rev-parse", "HEAD"], cwd, binary),
patch(["diff", "--binary"]),
patch(["diff", "--cached", "--binary"]),
raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) =>
raw(["ls-files", "--others", "--exclude-standard"], cwd, binary).then((s: string) =>
s.split("\n").filter((l: string) => l.length > 0),
),
])
Expand Down Expand Up @@ -111,24 +116,25 @@ export async function apply(
snapshot: GitSnapshot,
target: string,
log: (...args: unknown[]) => void,
binary = "git",
): Promise<{ ok: boolean; error?: string }> {
// Apply staged patch first, then re-stage those files
if (snapshot.staged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged)
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged, binary)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply staged patch:", msg)
return { ok: false, error: `Staged patch failed: ${msg}` }
}
const files = parsePatchFiles(snapshot.staged)
if (files.length > 0) {
await git(["add", "--", ...files], target)
await git(["add", "--", ...files], target, undefined, binary)
}
}

// Apply unstaged patch (leave as unstaged working-tree changes)
if (snapshot.unstaged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged)
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged, binary)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply unstaged patch:", msg)
Expand Down
14 changes: 13 additions & 1 deletion packages/kilo-vscode/src/agent-manager/project/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import simpleGit from "simple-git"
import type { GitOps } from "../GitOps"
import type { AgentManagerInMessage } from "../types"
import type { ProjectRegistry } from "./registry"
import type { ProjectContext, ProjectInitResult } from "./context"
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface ProjectMessageDeps {
ready: (ctx: ProjectContext) => Promise<ProjectInitResult>
/** Route one session to a directory inside a project (session override + project route). */
routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void
git?: GitOps
log: (...args: unknown[]) => void
}

Expand Down Expand Up @@ -206,7 +208,17 @@ async function addProject(deps: ProjectMessageDeps): Promise<void> {
if (!dir) return
// resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree
// registers the primary checkout and cannot duplicate an existing project.
const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args))
const git = deps.git
const root = await resolveProjectRoot(
dir,
git
? async (cwd, args) => {
const result = await git.execGit(args, cwd)
if (result.code !== 0) throw new Error(result.stderr)
return result.stdout
}
: (cwd, args) => simpleGit(cwd).raw(args),
)
if (!root) {
deps.error("The selected folder is not inside a Git repository.")
return
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/project/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function createProjectWiring(opts: {
pushState: opts.pushState,
selected: opts.selected,
routeSession: opts.routeSession,
git: opts.git,
error: (message) => opts.host.showError(message),
openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId),
log: opts.log,
Expand Down
11 changes: 9 additions & 2 deletions packages/kilo-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => {
// keybindings, autocomplete, commit-message generation, and URI deep links all work immediately —
// without requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here;
// it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it.
export function activate(context: vscode.ExtensionContext) {
export async function activate(context: vscode.ExtensionContext) {
console.log("Kilo Code extension is now active")
shuttingDown = false

Expand Down Expand Up @@ -162,9 +162,16 @@ export function activate(context: vscode.ExtensionContext) {
// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
const git = createGitExecutable({
preferred: async () => {
const extension = vscode.extensions.getExtension("vscode.git")
if (!extension) return undefined
if (!extension.isActive) await extension.activate()
return extension.exports?.getAPI(1).git.path
},
log: (message) => console.warn(`[Kilo New] ${message}`),
})
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git)
const binary = process.platform === "win32" ? await git() : git
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
context.subscriptions.push(agentManagerProvider)
Expand Down
Loading
Loading