Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/preserve-agent-manager-branches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Preserve explicit Git branch names in Agent Manager and expose automatic branch naming and prefix controls in its settings.
3 changes: 3 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3906,11 +3906,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

private configSettings() {
const naming = vscode.workspace.getConfiguration("kilo-code.new.agentManager")
return {
maxCost: this.maxCostSetting(),
languageCommitMessage: this.commitMessageLanguageSetting(),
multiProject: this.multiProjectSetting(),
browserAutomation: this.browserAutomationSetting(),
"agentManager.autoBranchNaming": naming.get<boolean>("autoBranchNaming", true),
"agentManager.branchPrefix": naming.get<string>("branchPrefix", ""),
}
}

Expand Down
55 changes: 45 additions & 10 deletions packages/kilo-vscode/src/agent-manager/WorktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import * as path from "path"
import * as fs from "fs"
import { randomUUID } from "crypto"
import { createHash, randomUUID } from "crypto"
import simpleGit, { type SimpleGit } from "simple-git"
import { generateBranchName, sanitizeBranchName } from "./branch-name"
import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps"
Expand All @@ -32,6 +32,20 @@ import {
const TEMP_PREFIX = ".kilo-delete-"
const RM_OPTS: fs.RmOptions = { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }

function directory(branch: string): string {
// Keep ordinary directory names, but isolate refs that need filesystem escaping.
if (
/^[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,99}$/.test(branch) &&
!/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i.test(branch)
) {
return branch
}
// Hash the original ref to distinguish names that produce the same shortened slug.
const slug = sanitizeBranchName(branch) || "branch"
const hash = createHash("sha256").update(branch).digest("hex").slice(0, 16)
return `${slug}-${hash}`
}

export interface WorktreeInfo {
branch: string
path: string
Expand Down Expand Up @@ -227,6 +241,13 @@ export class WorktreeManager {
"This folder is not a git repository. Initialize a repository or open a git project to use worktrees.",
)

const requested = params.existingBranch ?? params.branchName
if (requested !== undefined) {
// Validate the literal ref first so --branch cannot expand checkout shorthand.
await this.git.raw(["check-ref-format", `refs/heads/${requested}`])
await this.git.raw(["check-ref-format", "--branch", requested])
}

// Git LFS Pre-flight Check
if (await this.repoUsesLfs()) {
if (!(await this.checkLfsAvailable())) {
Expand Down Expand Up @@ -273,10 +294,10 @@ export class WorktreeManager {
}

let branch = await this.resolveBranch(params)
const dirName = branch.replace(/\//g, "-")
const dirName = directory(branch)
let worktreePath = path.join(this.dir, dirName)

await this.prepareWorktreePath(worktreePath, !!params.existingBranch)
worktreePath = await this.prepareWorktreePath(worktreePath, params.existingBranch)

params.onProgress?.("creating", `Creating worktree for ${branch}...`)

Expand All @@ -301,7 +322,7 @@ export class WorktreeManager {
}
// Another process may create the branch after resolveBranch checks it.
branch = await this.resolveBranch(params)
const retryDir = branch.replace(/\//g, "-")
const retryDir = directory(branch)
worktreePath = path.join(this.dir, retryDir)
const retryArgs = params.existingBranch
? ["worktree", "add", worktreePath, branch]
Expand Down Expand Up @@ -354,11 +375,26 @@ export class WorktreeManager {
return branch
}

private async prepareWorktreePath(worktreePath: string, reuse: boolean): Promise<void> {
if (!fs.existsSync(worktreePath)) return
if (!reuse) throw new Error(`Worktree path already exists: ${worktreePath}`)
private async prepareWorktreePath(worktreePath: string, branch?: string): Promise<string> {
if (!fs.existsSync(worktreePath)) return worktreePath
if (!branch) throw new Error(`Worktree path already exists: ${worktreePath}`)
const entries = parseWorktreeList(await this.git.raw(["worktree", "list", "--porcelain"]))
const canonical = normalizePath(await fs.promises.realpath(worktreePath))
const entry = entries.find((entry) => normalizePath(entry.path) === canonical)
if (entry && (entry.branch !== branch || entry.detached || entry.bare)) {
// A literal branch can match another ref's hashed directory name.
const parent = await fs.promises.realpath(path.dirname(worktreePath))
for (let suffix = 2; ; suffix++) {
const candidate = `${worktreePath}-${suffix}`
const canonical = normalizePath(path.join(parent, path.basename(candidate)))
if (!fs.existsSync(candidate) && !entries.some((entry) => normalizePath(entry.path) === canonical)) {
return candidate
}
}
}
this.log(`Worktree directory exists, cleaning up before re-creation: ${worktreePath}`)
await this.removeWorktreeImpl(worktreePath)
return worktreePath
}

private async resolveBranch(params: {
Expand All @@ -376,15 +412,14 @@ export class WorktreeManager {
.raw(["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
.then((refs) => refs.trim().split(/\r?\n/).filter(Boolean))
.catch(() => [] as string[])
const sanitized = params.branchName ? sanitizeBranchName(params.branchName) : undefined
const branch = sanitized || generateBranchName(params.prompt || "agent-task", existing)
const branch = params.branchName ?? generateBranchName(params.prompt || "agent-task", existing)
return this.availableBranch(branch, existing)
}

private availableBranch(base: string, existing: string[]): string {
const branches = new Set(existing)
const available = (branch: string) => {
const dir = path.join(this.dir, branch.replace(/\//g, "-"))
const dir = path.join(this.dir, directory(branch))
return !branches.has(branch) && !fs.existsSync(dir)
}
if (available(base)) return base
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getErrorMessage } from "../kilo-provider-utils"
import { PLATFORM } from "./constants"
import type { ProjectContext } from "./project/context"
import type { AgentManagerInMessage } from "./types"
import { versionedName } from "./branch-name"
import { sanitizeBranchName, versionedName } from "./branch-name"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
import { ensureSandbox } from "./sandbox-bootstrap"
import type { LifecycleHost } from "./provider-lifecycle"
Expand Down Expand Up @@ -37,7 +37,7 @@ export async function createMultiVersion(
const agent = msg.agent
const files = msg.files
const baseBranch = msg.baseBranch
const branchName = msg.branchName?.trim() || undefined
const branchName = msg.branchName || undefined

const fallback = msg.providerID && msg.modelID ? { providerID: msg.providerID, modelID: msg.modelID } : undefined
const resolved = resolveVersionModels(msg.modelAllocations, fallback, Number(msg.versions) || 1)
Expand Down Expand Up @@ -157,10 +157,12 @@ async function prepareVersion(host: MultiVersionHost, spec: VersionSpec): Promis
host.log(`Creating worktree ${spec.index + 1}/${spec.versions}`)

const version = versionedName(spec.branchName || spec.worktreeName, spec.index, spec.versions)
// Display names retain automatic slugging; explicit Git branches stay literal.
const branch = spec.branchName ? version.branch : sanitizeBranchName(version.branch ?? "") || undefined
const wt = await host.createOnDisk({
groupId: spec.groupId,
baseBranch: spec.baseBranch,
branchName: version.branch,
branchName: branch,
name: version.branch,
label: version.label,
})
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/agent-manager/tool-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ async function worktree(
versions?: boolean,
source?: ToolSource,
) {
const baseBranch = branch(task.branchName) ?? branch(task.name)
const baseBranch = task.branchName ?? branch(task.name)
const baseLabel = label(task.name) ?? label(task.branchName) ?? label(task.prompt)
const version = versionedName(baseBranch, versions ? index : 0, versions ? total : 1)
const created = await deps.createWorktree({
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-vscode/src/kilo-provider/config-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { retry } from "../services/cli-backend/retry"
import type { ConfigTarget } from "./config-bindings"

type Client = Pick<KiloClient, "config" | "global" | "experimental">
type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean }
type Settings = {
maxCost: number
languageCommitMessage: string
multiProject: boolean
"agentManager.autoBranchNaming": boolean
"agentManager.branchPrefix": string
}
export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) {
const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([
retry(() => client.config.get({ directory: dir }, { throwOnError: true })),
Expand Down
60 changes: 60 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ describe("Agent Manager settings navigation", () => {
expect(settings).toContain('type: "configureAgentManagerSetupScript"')
})

it("stages application-wide branch naming controls outside the project selection", () => {
expect(settings).toContain('updateSetting("agentManager.autoBranchNaming", value)')
expect(settings).toContain('updateSetting("agentManager.branchPrefix", value)')
expect(settings.indexOf('title={language.t("agentManager.settings.branchPrefix.title")}')).toBeLessThan(
settings.indexOf("<Show when={project()}"),
)
})

it("keeps the repository default branch selectable without an empty value", () => {
expect(settings).toContain("<ProjectBranchDialog")
expect(branchDialog).toContain('label: language.t("agentManager.worktree.defaultBaseBranchAuto")')
Expand All @@ -48,3 +56,55 @@ describe("Agent Manager settings navigation", () => {
expect(settings).toContain("<ProjectBranchDialog")
})
})

describe("Agent Manager application settings", () => {
it.each([
[undefined, undefined, true, ""],
[false, "team/", false, "team/"],
[true, "", true, ""],
] as const)("loads and saves naming preferences (%s, %s)", async (enabled, prefix, expected, text) => {
const vscode = await import("vscode")
const { KiloProvider } = await import("../../src/KiloProvider")
const original = vscode.workspace.getConfiguration
const values = new Map<string, unknown>()
if (enabled !== undefined) values.set("autoBranchNaming", enabled)
if (prefix !== undefined) values.set("branchPrefix", prefix)
const writes: unknown[] = []
vscode.workspace.getConfiguration = ((section?: string) => ({
get: (key: string, fallback: unknown) =>
section === "kilo-code.new.agentManager" && values.has(key) ? values.get(key) : fallback,
update: async (key: string, value: unknown, target: unknown) => {
writes.push({ section, key, value, target })
values.set(key, value)
},
})) as typeof original
try {
const provider = new KiloProvider({} as never, {} as never) as unknown as {
configSettings(): Record<string, unknown>
handleUpdateSetting(key: string, value: unknown): Promise<void>
}
expect(provider.configSettings()["agentManager.autoBranchNaming"]).toBe(expected)
expect(provider.configSettings()["agentManager.branchPrefix"]).toBe(text)
await provider.handleUpdateSetting("agentManager.autoBranchNaming", !expected)
await provider.handleUpdateSetting("agentManager.branchPrefix", "")
expect(writes).toEqual([
{
section: "kilo-code.new.agentManager",
key: "autoBranchNaming",
value: !expected,
target: vscode.ConfigurationTarget.Global,
},
{
section: "kilo-code.new.agentManager",
key: "branchPrefix",
value: "",
target: vscode.ConfigurationTarget.Global,
},
])
expect(provider.configSettings()["agentManager.autoBranchNaming"]).toBe(!expected)
expect(provider.configSettings()["agentManager.branchPrefix"]).toBe("")
} finally {
vscode.workspace.getConfiguration = original
}
})
})
22 changes: 16 additions & 6 deletions packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,15 @@ describe("agent manager tool start", () => {
tasks: [
{
prompt: "Fix",
branchName: "fix/one",
branchName: "fix/One_two.3",
model: { providerID: "test", modelID: "reasoning/model" },
variant: "low",
},
],
})

expect(c.createWorktree).toHaveBeenCalledWith(
expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }),
expect.objectContaining({ branchName: "fix/One_two.3", name: "fix/One_two.3", label: "one two 3" }),
)
expect(c.setup).toHaveBeenCalled()
expect(c.createSessionInWorktree).toHaveBeenCalledWith("/repo/.kilo/worktrees/wt-1", "kilo/test", "wt-1", {
Expand Down Expand Up @@ -350,7 +350,7 @@ describe("agent manager tool start", () => {
})
expect(normal.createWorktree).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ branchName: "fix-two", label: "two" }),
expect.objectContaining({ branchName: "fix/two", label: "two" }),
)

const grouped = deps()
Expand All @@ -365,11 +365,11 @@ describe("agent manager tool start", () => {
})
expect(grouped.createWorktree).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ branchName: "try-work_v2", label: "try work v2" }),
expect.objectContaining({ branchName: "try/work_v2", label: "try work v2" }),
)
})

it("sanitizes branch names and keeps card labels short", async () => {
it("passes invalid explicit names to worktree validation and keeps card labels short", async () => {
const c = deps()
await startFromTool(c, {
requestID: "am-name",
Expand All @@ -385,12 +385,22 @@ describe("agent manager tool start", () => {

expect(c.createWorktree).toHaveBeenCalledWith(
expect.objectContaining({
branchName: "fix-command-permissions-persistence",
branchName: "fix command permissions @#$ persistence",
label: "command permissions",
}),
)
})

it("still sanitizes display names used as automatic branch seeds", async () => {
const c = deps()
await startFromTool(c, {
requestID: "am-seed",
mode: "worktree",
tasks: [{ name: "My Feature" }],
})
expect(c.createWorktree).toHaveBeenCalledWith(expect.objectContaining({ branchName: "my-feature" }))
})

it("rejects local sessions for unknown worktree directories", async () => {
const client = {
session: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
languageCommitMessage: "sync",
multiProject: false,
browserAutomation: false,
"agentManager.autoBranchNaming": true,
"agentManager.branchPrefix": "",
})
const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings)
const provider = new KiloProvider({} as never, conn.service as never)
Expand Down Expand Up @@ -187,7 +189,7 @@
settings: snapshot.settings,
features: snapshot.features,
}
expect(sent.map(strip)).toEqual([

Check failure on line 192 in packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts

View workflow job for this annotation

GitHub Actions / unit tests

error: expect(received).toEqual(expected)

[ { + "collections": undefined, "config": {}, "features": { "backgroundSubagents": true, "indexing": false, "sandboxControls": true, }, "globalConfig": {}, "projectConfig": {}, "settings": { - "agentManager.autoBranchNaming": true, - "agentManager.branchPrefix": "", + "agentManager.autoBranchNaming": false, + "agentManager.branchPrefix": false, "browserAutomation": false, "languageCommitMessage": "sync", "maxCost": 0, "multiProject": false, }, "type": "configLoaded", }, { + "collections": undefined, "config": {}, "features": { "backgroundSubagents": true, "indexing": false, "sandboxControls": true, }, "globalConfig": {}, "projectConfig": {}, "settings": { - "agentManager.autoBranchNaming": true, - "agentManager.branchPrefix": "", + "agentManager.autoBranchNaming": false, + "agentManager.branchPrefix": false, "browserAutomation": false, "languageCommitMessage": "sync", "maxCost": 0, "multiProject": false, }, "type": "configUpdated", }, { "config": {}, "features": { "backgroundSubagents": true, "indexing": false, "sandboxControls": true, }, "globalConfig": {}, "projectConfig": {}, "settings": { - "agentManager.autoBranchNaming": true, - "agentManager.branchPrefix": "", + "agentManager.autoBranchNaming": false, + "agentManager.branchPrefix": false, "browserAutomation": false, "languageCommitMessage": "sync", "maxCost": 0, "multiProject": false, }, "type": "configUpdated", }, ] - Expected - 6 + Received + 8 at <anonymous> (/home/runner/_work/kilocode/kilocode/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts:192:29)
{ type: "configLoaded", ...payload },
{ type: "configUpdated", ...payload },
{ type: "configUpdated", ...payload },
Expand Down
Loading
Loading