feat(KB-036): add worktreeNaming setting for configurable worktree directory names
- Add worktreeNaming setting type with 'random' | 'task-id' | 'task-title' options - Update executor to generate worktree names based on setting value - Create worktree-names.ts utility module with generateWorktreeName function - Add comprehensive executor tests for all naming strategies - Update AGENTS.md documentation with setting description and usage - Include changeset for patch release
This commit is contained in:
@@ -201,6 +201,13 @@ export interface Settings {
|
||||
* of being deleted. New tasks acquire a warm worktree from the pool,
|
||||
* preserving build caches (node_modules, target/, dist/). Default: false. */
|
||||
recycleWorktrees?: boolean;
|
||||
/** Controls how worktree directory names are generated when creating fresh worktrees.
|
||||
* Only applies when recycleWorktrees is NOT enabled (pooled worktrees retain their existing names).
|
||||
* - "random": Human-friendly adjective-noun names (e.g., swift-falcon) — default
|
||||
* - "task-id": Use the task ID (e.g., kb-042)
|
||||
* - "task-title": Use a slugified version of the task title (e.g., fix-login-bug)
|
||||
* Default: "random". */
|
||||
worktreeNaming?: "random" | "task-id" | "task-title";
|
||||
/** Prefix for generated task IDs (e.g. `"KB"` produces `KB-001`).
|
||||
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||
* their original IDs. */
|
||||
@@ -261,6 +268,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
autoMerge: true,
|
||||
worktreeInitCommand: undefined,
|
||||
recycleWorktrees: false,
|
||||
worktreeNaming: "random",
|
||||
taskPrefix: undefined,
|
||||
includeTaskIdInCommit: true,
|
||||
defaultProvider: undefined,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function SettingsModal({
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
}: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, includeTaskIdInCommit: true, worktreeInitCommand: "" });
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
@@ -425,6 +425,26 @@ export function SettingsModal({
|
||||
</label>
|
||||
<small>When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeNaming">Worktree Naming Style</label>
|
||||
<select
|
||||
id="worktreeNaming"
|
||||
value={form.worktreeNaming || "random"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))
|
||||
}
|
||||
disabled={form.recycleWorktrees}
|
||||
>
|
||||
<option value="random">Random names (e.g., swift-falcon)</option>
|
||||
<option value="task-id">Task ID (e.g., kb-042)</option>
|
||||
<option value="task-title">Task title (e.g., fix-login-bug)</option>
|
||||
</select>
|
||||
<small>
|
||||
{form.recycleWorktrees
|
||||
? "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names"
|
||||
: "How to name fresh worktree directories. Only applies when recycling is off."}
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "commands":
|
||||
|
||||
@@ -15,9 +15,13 @@ vi.mock("./merger.js", async (importOriginal) => {
|
||||
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
});
|
||||
vi.mock("./worktree-names.js", () => ({
|
||||
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
|
||||
}));
|
||||
vi.mock("./worktree-names.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
|
||||
return {
|
||||
...actual,
|
||||
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock node modules used by executor
|
||||
vi.mock("node:child_process", () => ({
|
||||
@@ -33,7 +37,7 @@ import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
||||
import { WorktreePool } from "./worktree-pool.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import type { Column, Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||
@@ -360,8 +364,8 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
||||
describe("TaskExecutor worktree naming", () => {
|
||||
const makeTask = (id = "KB-030", worktree?: string) => ({
|
||||
id,
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
title: "Test Task Title",
|
||||
description: "Test description for task",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
@@ -427,6 +431,158 @@ describe("TaskExecutor worktree naming", () => {
|
||||
// Should NOT generate a new name — reuse the stored path
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("worktreeNaming setting", () => {
|
||||
it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeNaming: "task-id",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask("KB-042"));
|
||||
|
||||
// Should use task ID (lowercase) as worktree name
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-042", {
|
||||
worktree: "/tmp/test/.worktrees/kb-042",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using task-id
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses slugified task title as worktree name when worktreeNaming is 'task-title'", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeNaming: "task-title",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
...makeTask("KB-043"),
|
||||
title: "Fix login bug with OAuth",
|
||||
});
|
||||
|
||||
// Should use slugified title as worktree name
|
||||
const expectedSlug = slugify("Fix login bug with OAuth");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-043", {
|
||||
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to description when title is empty for 'task-title' mode", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeNaming: "task-title",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const taskDescription = "Implement user authentication flow";
|
||||
await executor.execute({
|
||||
...makeTask("KB-044"),
|
||||
title: "",
|
||||
description: taskDescription,
|
||||
});
|
||||
|
||||
// Should slugify the first 60 chars of description when title is empty
|
||||
const expectedSlug = slugify(taskDescription.slice(0, 60));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-044", {
|
||||
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses generateWorktreeName when worktreeNaming is 'random'", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeNaming: "random",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask("KB-045"));
|
||||
|
||||
// Should use generateWorktreeName for random mode
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-045", {
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
});
|
||||
|
||||
it("defaults to random naming when worktreeNaming is undefined", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
// worktreeNaming is not set (undefined)
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask("KB-046"));
|
||||
|
||||
// Should default to random naming
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-046", {
|
||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||
});
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||
});
|
||||
|
||||
it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => {
|
||||
const pool = new WorktreePool();
|
||||
pool.release("/tmp/test/.worktrees/pooled-warm-wt");
|
||||
// Pool path exists on disk, task worktree path does not (not a resume)
|
||||
mockedExistsSync.mockImplementation(
|
||||
(p) => p === "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
);
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
recycleWorktrees: true,
|
||||
worktreeNaming: "task-id", // This should be ignored for pooled worktrees
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||
await executor.execute(makeTask("KB-047"));
|
||||
|
||||
// Should acquire from pool, ignoring the task-id naming preference
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-047", {
|
||||
worktree: "/tmp/test/.worktrees/pooled-warm-wt",
|
||||
});
|
||||
// Should NOT call generateWorktreeName when using pooled worktree
|
||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||
// Should log pool acquisition
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-047",
|
||||
expect.stringContaining("Acquired worktree from pool"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@kb/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName } from "./worktree-names.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
@@ -292,8 +292,32 @@ export class TaskExecutor {
|
||||
|
||||
executorLog.log(`Starting ${task.id}: ${task.title || task.description.slice(0, 60)}`);
|
||||
|
||||
// Fetch settings early — needed for worktree naming and later configuration
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
// Hoist worktreePath so it's accessible in the catch block for dep-abort cleanup
|
||||
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
||||
// Determine worktree name based on settings
|
||||
let worktreePath: string;
|
||||
if (task.worktree) {
|
||||
worktreePath = task.worktree;
|
||||
} else {
|
||||
const naming = settings.worktreeNaming || "random";
|
||||
let worktreeName: string;
|
||||
|
||||
switch (naming) {
|
||||
case "task-id":
|
||||
worktreeName = task.id.toLowerCase();
|
||||
break;
|
||||
case "task-title":
|
||||
worktreeName = slugify(task.title || task.description.slice(0, 60));
|
||||
break;
|
||||
case "random":
|
||||
default:
|
||||
worktreeName = generateWorktreeName(this.rootDir);
|
||||
break;
|
||||
}
|
||||
worktreePath = join(this.rootDir, ".worktrees", worktreeName);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check dependencies
|
||||
@@ -314,7 +338,6 @@ export class TaskExecutor {
|
||||
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
|
||||
let isResume = existsSync(worktreePath);
|
||||
let acquiredFromPool = false;
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
// Resolve the base branch — set by the scheduler when a dep is in-review
|
||||
const baseBranch = task.baseBranch || null;
|
||||
|
||||
@@ -28,6 +28,23 @@ export const NOUNS = [
|
||||
"thorn", "tiger", "trail", "trout", "wren",
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert a string to a URL-friendly slug.
|
||||
*
|
||||
* - Lowercase
|
||||
* - Replace spaces, underscores, and special chars with hyphens
|
||||
* - Collapse multiple hyphens
|
||||
* - Trim leading/trailing hyphens
|
||||
*/
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and hyphens
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ""); // Trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random, human-friendly worktree directory name.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user