mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(vscode): honor configured Git executable for worktrees
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use the Git executable configured in VS Code when creating worktrees on Windows.
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user