From be2ec51159fdbeeee852c1e25682f3b1cc46036e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 26 Aug 2026 16:02:56 +0200 Subject: [PATCH] fix(vscode): honor configured Git executable for worktrees --- .changeset/fix-windows-worktree-git.md | 5 ++ .github/workflows/test-vscode.yml | 28 ++++++ .../src/agent-manager/AgentManagerProvider.ts | 4 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 12 ++- .../src/agent-manager/WorktreeManager.ts | 46 ++++++---- .../src/agent-manager/continue-in-worktree.ts | 5 +- .../src/agent-manager/git-transfer.ts | 32 ++++--- .../src/agent-manager/project/messages.ts | 14 ++- .../src/agent-manager/project/wiring.ts | 1 + packages/kilo-vscode/src/extension.ts | 11 ++- .../kilo-vscode/src/util/git-executable.ts | 9 ++ .../tests/unit/agent-project-messages.test.ts | 17 +++- .../tests/unit/git-executable.test.ts | 50 +++++++++++ .../kilo-vscode/tests/unit/git-ops.test.ts | 19 ++++ .../tests/unit/worktree-manager.test.ts | 88 +++++++++++++++++++ 15 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 .changeset/fix-windows-worktree-git.md diff --git a/.changeset/fix-windows-worktree-git.md b/.changeset/fix-windows-worktree-git.md new file mode 100644 index 0000000000..887097f6c3 --- /dev/null +++ b/.changeset/fix-windows-worktree-git.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the Git executable configured in VS Code when creating worktrees on Windows. diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml index a19617ef32..9eef7ea2f6 100644 --- a/.github/workflows/test-vscode.yml +++ b/.github/workflows/test-vscode.yml @@ -53,3 +53,31 @@ jobs: - name: Check for kilocode_change markers working-directory: packages/kilo-vscode run: bun run check-kilocode-change + + configured-git: + name: configured Git executable + runs-on: blacksmith-4vcpu-windows-2025 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Run Windows Git executable resolution regression + working-directory: packages/kilo-vscode + run: bun test tests/unit/git-executable.test.ts --test-name-pattern "configured Git executable" --timeout 120000 + + - name: Run Windows worktree creation regression + working-directory: packages/kilo-vscode + run: bun test tests/unit/worktree-manager.test.ts --test-name-pattern "configured Git executable" --timeout 120000 + + - name: Run Windows Git operations regression + working-directory: packages/kilo-vscode + run: bun test tests/unit/git-ops.test.ts --test-name-pattern "explicit Git executable path" --timeout 120000 + + - name: Run Windows project discovery regression + working-directory: packages/kilo-vscode + run: bun test tests/unit/agent-project-messages.test.ts --test-name-pattern "configured Git executable" --timeout 120000 diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index f25e48235c..57cc451f05 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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( @@ -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), diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index e31ebc00c7..bc1a4a27bf 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -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 { @@ -131,6 +131,8 @@ 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 } @@ -138,7 +140,12 @@ export class GitOps { 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 ?? @@ -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()) diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index adb0a510a9..5d162adf30 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -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 } @@ -121,8 +123,8 @@ export class WorktreeManager { // Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done private static fetchCache = new Map() 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(fn: () => Promise): Promise { const key = this.root @@ -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) // --------------------------------------------------------------------------- @@ -169,7 +181,7 @@ export class WorktreeManager { async hasWork(worktreePath: string, base: string): Promise { 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 @@ -185,12 +197,12 @@ export class WorktreeManager { } private async ensureGitAvailable(): Promise { - 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.", @@ -313,7 +325,7 @@ export class WorktreeManager { private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise { 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") @@ -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), @@ -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()) @@ -925,13 +937,13 @@ export class WorktreeManager { } async checkLfsAvailable(): Promise { - 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 } @@ -1165,7 +1177,7 @@ export class WorktreeManager { } private async gitExec(args: string[]): Promise { - await this.exec("git", args) + await this.exec(this.binary, args) } private async gitTry(args: string[]): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 853221dde8..7c2c8fa5cf 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -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 } @@ -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> { 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)}` } @@ -67,7 +68,7 @@ export async function transferState( snapshot: GitSnapshot, target: string, ): Promise> { - 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" } diff --git a/packages/kilo-vscode/src/agent-manager/git-transfer.ts b/packages/kilo-vscode/src/agent-manager/git-transfer.ts index 54916204c7..1beb223ce2 100644 --- a/packages/kilo-vscode/src/agent-manager/git-transfer.ts +++ b/packages/kilo-vscode/src/agent-manager/git-transfer.ts @@ -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())) @@ -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) => { @@ -58,8 +63,8 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe }) } -async function raw(args: string[], cwd: string): Promise { - const result = await git(args, cwd) +async function raw(args: string[], cwd: string, binary = "git"): Promise { + const result = await git(args, cwd, undefined, binary) return result.stdout.trim() } @@ -67,19 +72,19 @@ async function raw(args: string[], cwd: string): Promise { * 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 { +export async function capture(cwd: string, log: (...args: unknown[]) => void, binary = "git"): Promise { 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), ), ]) @@ -111,10 +116,11 @@ 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) @@ -122,13 +128,13 @@ export async function apply( } 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) diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index d3fa3bb80b..7adb073823 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -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" @@ -57,6 +58,7 @@ export interface ProjectMessageDeps { ready: (ctx: ProjectContext) => Promise /** 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 } @@ -206,7 +208,17 @@ async function addProject(deps: ProjectMessageDeps): Promise { 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 diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 59a47ed011..065c6c71b6 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -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, diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 428ca16864..dcb13e2b34 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -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 @@ -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) diff --git a/packages/kilo-vscode/src/util/git-executable.ts b/packages/kilo-vscode/src/util/git-executable.ts index a4fa64f377..2d8b7c4338 100644 --- a/packages/kilo-vscode/src/util/git-executable.ts +++ b/packages/kilo-vscode/src/util/git-executable.ts @@ -11,6 +11,7 @@ interface GitExecutableOptions { run?: (cmd: string, args: string[]) => Promise<{ stdout: string }> access?: (file: string, mode: number) => Promise realpath?: (file: string) => Promise + preferred?: () => Promise log?: (message: string) => void } @@ -29,6 +30,14 @@ export function createGitExecutable(options: GitExecutableOptions = {}): GitExec return (): Promise => { cached ??= (async () => { + if (platform === "win32") { + try { + return (await options.preferred?.()) ?? "git" + } catch (err) { + log(`Unable to resolve the preferred Git executable, using PATH: ${err}`) + return "git" + } + } if (platform !== "darwin") return "git" try { diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts index ed8542518d..0b4ba95c8a 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -3,6 +3,7 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import { execFileSync } from "child_process" +import { GitOps } from "../../src/agent-manager/GitOps" import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry" import { ProjectContexts } from "../../src/agent-manager/project/contexts" @@ -17,7 +18,7 @@ function gitRepo(): string { return fs.realpathSync(dir) } -function setup(opts: { enabled?: boolean; workspace?: string } = {}) { +function setup(opts: { enabled?: boolean; workspace?: string; git?: GitOps } = {}) { let stored: unknown let pickResult: string | undefined const storage: RegistryStorage = { @@ -58,6 +59,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) { calls.ready.push(ctx.id) return calls.readyResult }, + git: opts.git, log: () => {}, } const pick = (dir: string | undefined) => { @@ -118,6 +120,19 @@ describe("handleProjectMessage", () => { expect(calls.error).toEqual(["The selected folder is not inside a Git repository."]) }) + it("uses the configured Git executable when adding a project", async () => { + const repo = gitRepo() + const git = new GitOps({ log: () => {}, binary: path.join(repo, "missing-git") }) + const { deps, calls, registry, pick } = setup({ git }) + pick(repo) + + await handleProjectMessage(msg("agentManager.addProject"), deps) + git.dispose() + + expect(registry.list()).toEqual([]) + expect(calls.error).toEqual(["The selected folder is not inside a Git repository."]) + }) + it("rejects the pinned workspace repository", async () => { const repo = gitRepo() const { deps, calls, pick } = setup({ workspace: repo }) diff --git a/packages/kilo-vscode/tests/unit/git-executable.test.ts b/packages/kilo-vscode/tests/unit/git-executable.test.ts index f6850a391d..0607279b56 100644 --- a/packages/kilo-vscode/tests/unit/git-executable.test.ts +++ b/packages/kilo-vscode/tests/unit/git-executable.test.ts @@ -2,6 +2,56 @@ import { describe, expect, it } from "bun:test" import { createGitExecutable } from "../../src/util/git-executable" describe("createGitExecutable", () => { + it("uses the configured Git executable on Windows", async () => { + const git = createGitExecutable({ + platform: "win32", + preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe", + }) + + expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe") + }) + + it("falls back to PATH when the preferred Windows executable is missing", async () => { + const git = createGitExecutable({ + platform: "win32", + preferred: async () => undefined, + }) + + expect(await git()).toBe("git") + }) + + it("logs and falls back to PATH when preferred Windows resolution fails", async () => { + const messages: string[] = [] + const git = createGitExecutable({ + platform: "win32", + preferred: async () => { + throw new Error("Git API unavailable") + }, + log: (message) => messages.push(message), + }) + + expect(await git()).toBe("git") + expect(messages).toEqual(["Unable to resolve the preferred Git executable, using PATH: Error: Git API unavailable"]) + }) + + it("caches the preferred Windows executable", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "win32", + preferred: async () => { + calls++ + return "C:\\Git\\git.exe" + }, + }) + + expect(await Promise.all([git(), git(), git()])).toEqual([ + "C:\\Git\\git.exe", + "C:\\Git\\git.exe", + "C:\\Git\\git.exe", + ]) + expect(calls).toBe(1) + }) + it("preserves PATH lookup on other platforms", async () => { const git = createGitExecutable({ platform: "linux", diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index c90aa3a7c0..803f730326 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -57,6 +57,25 @@ describe("GitOps", () => { }) }) + it("uses an explicit Git executable path with spaces", async () => { + await withRepo(async (cwd) => { + const real = Bun.which("git") + if (!real) throw new Error("Git is required for this test") + + const dir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops executable-")) + const binary = process.platform === "win32" ? real : nodePath.join(dir, "git") + try { + if (process.platform !== "win32") await fs.symlink(real, binary) + + const git = new GitOps({ log: () => undefined, binary }) + expect(git.path).toBe(binary) + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + }) + it("does not hold a semaphore slot while resolving Git", async () => { const semaphore = new Semaphore(1) let resolve!: (value: string) => void diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index b8f23e47b7..f65dc29a42 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -263,6 +263,94 @@ describe("WorktreeStateManager.updateWorktreeLabel", () => { // --------------------------------------------------------------------------- describe("WorktreeManager.createWorktree", () => { + it("uses a configured Git executable for worktree creation", async () => { + const root = await createTempRepo() + const real = Bun.which("git") + if (!real) throw new Error("Git is required for this test") + + const fake = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-no-git-")) + tempDirs.push(fake) + const file = path.join(fake, process.platform === "win32" ? "git.cmd" : "git") + await fs.writeFile(file, process.platform === "win32" ? "@exit /b 127\r\n" : "#!/bin/sh\nexit 127\n") + if (process.platform !== "win32") await fs.chmod(file, 0o755) + + const bin = + process.platform === "win32" + ? real + : path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-git executable-")), "git") + if (process.platform !== "win32") { + const dir = path.dirname(bin) + tempDirs.push(dir) + await fs.symlink(real, bin) + } + + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string" && key.toLowerCase() !== "path") env[key] = value + } + const key = Object.keys(process.env).find((name) => name.toLowerCase() === "path") ?? "PATH" + const dirs = [fake] + if (process.platform === "win32") { + const root = process.env.SystemRoot ?? process.env.windir + if (root) { + dirs.push( + path.join(root, "System32"), + path.join(root, "System32", "Wbem"), + path.join(root, "System32", "WindowsPowerShell", "v1.0"), + ) + } + } + env[key] = dirs.join(path.delimiter) + env.KILO_TEST_ROOT = root + env.KILO_TEST_GIT = bin + + const script = ` + import { existsSync } from "node:fs" + import path from "node:path" + import { GitOps } from "./src/agent-manager/GitOps" + import { apply, capture } from "./src/agent-manager/git-transfer" + import { WorktreeManager } from "./src/agent-manager/WorktreeManager" + + const root = process.env.KILO_TEST_ROOT + const git = process.env.KILO_TEST_GIT + if (!root || !git) throw new Error("Missing configured Git test environment") + + const ops = new GitOps({ log: () => undefined, binary: git }) + const manager = new WorktreeManager(root, () => undefined, ops) + const result = await manager.createWorktree({ branchName: "configured-git" }) + if (!existsSync(path.join(result.path, ".git"))) throw new Error("Worktree was not created") + if ((await ops.currentBranch(result.path)) !== result.branch) throw new Error("GitOps did not use configured Git") + if (await manager.hasWork(result.path, result.parentBranch)) throw new Error("New worktree unexpectedly has work") + await Bun.write(path.join(result.path, "configured.txt"), "configured") + if (!(await manager.hasWork(result.path, result.parentBranch))) throw new Error("WorktreeManager did not use configured Git") + await Bun.write(path.join(root, "README.md"), "staged\\n") + const staged = await ops.execGit(["add", "README.md"], root) + if (staged.code !== 0) throw new Error("Could not stage configured Git test change") + await Bun.write(path.join(root, "README.md"), "unstaged\\n") + const snapshot = await capture(root, () => undefined, git) + if (!snapshot.staged?.includes("staged") || !snapshot.unstaged?.includes("unstaged")) { + throw new Error("Git transfer did not capture staged and unstaged changes") + } + const applied = await apply(snapshot, result.path, () => undefined, git) + if (!applied.ok) throw new Error(applied.error ?? "Git transfer did not apply changes") + if ((await Bun.file(path.join(result.path, "README.md")).text()) !== "unstaged\\n") { + throw new Error("Git transfer did not apply the working tree content") + } + const status = (await ops.execGit(["status", "--porcelain", "--", "README.md"], result.path)).stdout.trim() + if (status !== "MM README.md") throw new Error("Git transfer did not preserve staged state: " + status) + ` + const child = Bun.spawnSync([process.execPath, "-e", script], { + cwd: process.cwd(), + env, + stdout: "pipe", + stderr: "pipe", + }) + const stderr = child.stderr.toString("utf8") + + expect(child.exitCode, stderr).toBe(0) + expect(existsSync(path.join(root, ".kilo", "worktrees", "configured-git", ".git"))).toBe(true) + }, 120_000) + it("creates a worktree with a new branch", async () => { const root = await createTempRepo() const mgr = createManager(root)