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:
Dustin Byrne
2026-03-25 22:14:14 -04:00
5 changed files with 199 additions and 2 deletions

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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();
});
});

View File

@@ -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);