fix(vscode): honor configured Git executable for worktrees

This commit is contained in:
marius-kilocode
2026-08-26 16:02:56 +02:00
parent ed3380ea49
commit be2ec51159
15 changed files with 301 additions and 40 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Use the Git executable configured in VS Code when creating worktrees on Windows.
+28
View File
@@ -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
@@ -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),
@@ -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())
@@ -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<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
@@ -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<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
@@ -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.",
@@ -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")
@@ -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<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
}
@@ -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> {
@@ -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<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)}` }
@@ -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" }
@@ -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<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()
}
@@ -67,19 +72,19 @@ async function raw(args: string[], cwd: string): Promise<string> {
* 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),
),
])
@@ -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)
@@ -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<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
}
@@ -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
@@ -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,
+9 -2
View File
@@ -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)
@@ -11,6 +11,7 @@ interface GitExecutableOptions {
run?: (cmd: string, args: string[]) => Promise<{ stdout: string }>
access?: (file: string, mode: number) => Promise<void>
realpath?: (file: string) => Promise<string>
preferred?: () => Promise<string | undefined>
log?: (message: string) => void
}
@@ -29,6 +30,14 @@ export function createGitExecutable(options: GitExecutableOptions = {}): GitExec
return (): Promise<string> => {
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 {
@@ -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 })
@@ -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",
@@ -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
@@ -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<string, string> = {}
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)