feat(HAI-026): add worktreeInitCommand setting for post-worktree-creation hooks
- Add worktreeInitCommand field to Settings type in core types - Execute worktreeInitCommand in executor after worktree creation - Add worktreeInitCommand UI field to SettingsModal for user configuration - Add settings round-trip tests for worktreeInitCommand persistence - Add executor tests for worktreeInitCommand execution behavior
This commit is contained in:
@@ -244,6 +244,21 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings tests ────────────────────────────────────────────────
|
||||
|
||||
describe("worktreeInitCommand setting", () => {
|
||||
it("persists worktreeInitCommand and returns it via getSettings", async () => {
|
||||
await store.updateSettings({ worktreeInitCommand: "pnpm install" });
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.worktreeInitCommand).toBe("pnpm install");
|
||||
});
|
||||
|
||||
it("default settings do not include worktreeInitCommand", async () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.worktreeInitCommand).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent stress test ───────────────────────────────────────
|
||||
|
||||
describe("concurrent stress", () => {
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface Settings {
|
||||
pollIntervalMs: number;
|
||||
groupOverlappingFiles: boolean;
|
||||
autoMerge: boolean;
|
||||
/** Shell command to run inside each new worktree immediately after creation.
|
||||
* Useful for project-specific setup (e.g. `pnpm install`, `cp .env.local .env`). */
|
||||
worktreeInitCommand?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -67,6 +70,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: undefined,
|
||||
};
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
const [form, setForm] = useState<Settings>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false });
|
||||
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, worktreeInitCommand: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +41,11 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
await updateSettings(form);
|
||||
const payload = {
|
||||
...form,
|
||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||
};
|
||||
await updateSettings(payload);
|
||||
addToast("Settings saved", "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@@ -116,6 +120,19 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
||||
</label>
|
||||
<small>When enabled, tasks that modify the same files are queued serially to avoid merge conflicts</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="worktreeInitCommand">Worktree Init Command</label>
|
||||
<input
|
||||
id="worktreeInitCommand"
|
||||
type="text"
|
||||
placeholder="pnpm install"
|
||||
value={form.worktreeInitCommand || ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<small>Shell command to run in each new worktree after creation</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("node:fs", () => ({
|
||||
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
||||
|
||||
@@ -49,6 +50,14 @@ function createMockStore() {
|
||||
moveTask: vi.fn().mockResolvedValue({}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: undefined,
|
||||
}),
|
||||
updateStep: vi.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
}
|
||||
@@ -165,3 +174,139 @@ describe("TaskExecutor with semaphore", () => {
|
||||
expect(sem.activeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
|
||||
describe("TaskExecutor worktreeInitCommand", () => {
|
||||
const makeTask = (id = "HAI-010") => ({
|
||||
id,
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: worktree does NOT exist (new worktree)
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("runs worktreeInitCommand in new worktree when configured", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: "pnpm install",
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// execSync is called for worktree creation + init command
|
||||
const initCall = mockedExecSync.mock.calls.find(
|
||||
(call) => call[0] === "pnpm install",
|
||||
);
|
||||
expect(initCall).toBeDefined();
|
||||
expect(initCall![1]).toMatchObject({
|
||||
cwd: expect.stringContaining("HAI-010"),
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Should log success
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"HAI-010",
|
||||
"Worktree init command completed",
|
||||
"pnpm install",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT run init command when worktreeInitCommand is not set", async () => {
|
||||
const store = createMockStore();
|
||||
// getSettings returns default (no worktreeInitCommand)
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// Only worktree creation calls to execSync, no "pnpm install" etc.
|
||||
const initCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && !call[0].startsWith("git"),
|
||||
);
|
||||
expect(initCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("catches init command failure and logs without aborting", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: "npm run setup",
|
||||
});
|
||||
|
||||
// Make the init command fail (but not git worktree commands)
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (cmd === "npm run setup") {
|
||||
const err: any = new Error("command failed");
|
||||
err.stderr = Buffer.from("setup script error");
|
||||
throw err;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// Should log the failure
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"HAI-010",
|
||||
expect.stringContaining("Worktree init command failed"),
|
||||
);
|
||||
|
||||
// Should NOT have called onError (task continues)
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
// Agent should still have been created
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT run init command on worktree resume", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: "pnpm install",
|
||||
});
|
||||
|
||||
// Worktree already exists (resume)
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
// getSettings should NOT have been called (skipped entire !isResume block)
|
||||
expect(store.getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +194,22 @@ export class TaskExecutor {
|
||||
if (!isResume) {
|
||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
|
||||
|
||||
// Run worktree init command if configured
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.worktreeInitCommand) {
|
||||
try {
|
||||
execSync(settings.worktreeInitCommand, {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
timeout: 120_000,
|
||||
});
|
||||
await this.store.logEntry(task.id, "Worktree init command completed", settings.worktreeInitCommand);
|
||||
} catch (err: any) {
|
||||
const message = err.stderr?.toString() || err.message || "Unknown error";
|
||||
await this.store.logEntry(task.id, `Worktree init command failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.options.onStart?.(task, worktreePath);
|
||||
|
||||
Reference in New Issue
Block a user