feat(HAI-033): add dependency-chain worktree reuse and conditional cleanup
- Add executor logic to resolve and reuse dependency worktrees for warm build caches - Add reuseWorktree method that creates a new branch in an existing worktree - Add findWorktreeUser helper to check if a worktree is shared across tasks - Update executor and merger cleanup to skip worktree removal when still in use - Add comprehensive tests for worktree reuse and conditional cleanup paths
This commit is contained in:
184
packages/engine/src/merger.test.ts
Normal file
184
packages/engine/src/merger.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock("./pi.js", () => ({
|
||||
createHaiAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { aiMergeTask, findWorktreeUser } from "./merger.js";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Task, MergeResult } from "@hai/core";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||
|
||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||
const baseTask: Task = {
|
||||
id: "HAI-050",
|
||||
title: "Test task",
|
||||
description: "Test",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
worktree: "/tmp/root/.worktrees/HAI-050",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...taskOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
|
||||
listTasks: vi.fn().mockResolvedValue(allTasks),
|
||||
updateTask: vi.fn().mockResolvedValue(baseTask),
|
||||
moveTask: vi.fn().mockResolvedValue(baseTask),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up execSync to handle the standard merge flow:
|
||||
* rev-parse, log, diff, merge --squash, diff --cached, branch -d
|
||||
*/
|
||||
function setupHappyPathExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
describe("findWorktreeUser", () => {
|
||||
it("returns null when no other task uses the worktree", async () => {
|
||||
const store = createMockStore({}, [
|
||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||
]);
|
||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns task ID when another non-done task uses the worktree", async () => {
|
||||
const store = createMockStore({}, [
|
||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||
{ id: "HAI-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
|
||||
]);
|
||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
||||
expect(result).toBe("HAI-051");
|
||||
});
|
||||
|
||||
it("ignores done tasks", async () => {
|
||||
const store = createMockStore({}, [
|
||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||
{ id: "HAI-051", worktree: "/tmp/wt", column: "done" } as Task,
|
||||
]);
|
||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — conditional worktree cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
setupHappyPathExecSync();
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("does NOT remove worktree when another task references the same path", async () => {
|
||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||
const store = createMockStore(
|
||||
{ id: "HAI-050", worktree: worktreePath },
|
||||
[
|
||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
|
||||
],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||
|
||||
// Worktree should NOT be removed
|
||||
const removeCall = mockedExecSync.mock.calls.find(
|
||||
(call) => String(call[0]).includes("worktree remove"),
|
||||
);
|
||||
expect(removeCall).toBeUndefined();
|
||||
expect(result.worktreeRemoved).toBe(false);
|
||||
});
|
||||
|
||||
it("removes worktree when no other task references it", async () => {
|
||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||
const store = createMockStore(
|
||||
{ id: "HAI-050", worktree: worktreePath },
|
||||
[
|
||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||
],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||
|
||||
const removeCall = mockedExecSync.mock.calls.find(
|
||||
(call) => String(call[0]).includes("worktree remove"),
|
||||
);
|
||||
expect(removeCall).toBeDefined();
|
||||
expect(result.worktreeRemoved).toBe(true);
|
||||
});
|
||||
|
||||
it("always deletes the branch regardless of worktree sharing", async () => {
|
||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||
const store = createMockStore(
|
||||
{ id: "HAI-050", worktree: worktreePath },
|
||||
[
|
||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
|
||||
],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||
|
||||
// Branch should be deleted even though worktree is shared
|
||||
const branchDeleteCall = mockedExecSync.mock.calls.find(
|
||||
(call) => String(call[0]).includes("branch -d") || String(call[0]).includes("branch -D"),
|
||||
);
|
||||
expect(branchDeleteCall).toBeDefined();
|
||||
expect(result.branchDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("result.worktreeRemoved is false when worktree is retained", async () => {
|
||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
||||
const store = createMockStore(
|
||||
{ id: "HAI-050", worktree: worktreePath },
|
||||
[
|
||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||
{ id: "HAI-051", worktree: worktreePath, column: "todo" } as Task,
|
||||
],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
||||
expect(result.worktreeRemoved).toBe(false);
|
||||
expect(result.merged).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user