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:
@@ -8,6 +8,9 @@ vi.mock("./pi.js", () => ({
|
||||
vi.mock("./reviewer.js", () => ({
|
||||
reviewStep: vi.fn(),
|
||||
}));
|
||||
vi.mock("./merger.js", () => ({
|
||||
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// Mock node modules used by executor
|
||||
vi.mock("node:child_process", () => ({
|
||||
@@ -20,6 +23,8 @@ vi.mock("node:fs", () => ({
|
||||
import { TaskExecutor } from "./executor.js";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import type { Column, Task } from "@hai/core";
|
||||
|
||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
||||
|
||||
@@ -310,3 +315,248 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
||||
expect(store.getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
|
||||
|
||||
describe("TaskExecutor worktree reuse", () => {
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "HAI-020",
|
||||
title: "Dependent task",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("reuses dependency worktree when dep has existing worktree on disk", async () => {
|
||||
const store = createMockStore();
|
||||
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
|
||||
|
||||
// Dep task is in-review with an existing worktree
|
||||
store.listTasks.mockResolvedValue([
|
||||
makeTask({
|
||||
id: "HAI-019",
|
||||
column: "in-review",
|
||||
worktree: depWorktreePath,
|
||||
dependencies: [],
|
||||
}),
|
||||
makeTask({
|
||||
id: "HAI-020",
|
||||
column: "in-progress",
|
||||
dependencies: ["HAI-019"],
|
||||
}),
|
||||
]);
|
||||
|
||||
// existsSync: dep worktree exists on disk
|
||||
mockedExistsSync.mockImplementation((p: any) => {
|
||||
return p === depWorktreePath;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
||||
|
||||
// Should call `git checkout -b` (reuse), NOT `git worktree add`
|
||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(checkoutCall![0]).toContain("hai/hai-020");
|
||||
|
||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
||||
);
|
||||
expect(worktreeAddCall).toBeUndefined();
|
||||
|
||||
// Task's worktree should be set to the reused path
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depWorktreePath });
|
||||
});
|
||||
|
||||
it("creates fresh worktree when dependency worktree does NOT exist on disk", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Dep is done but worktree was removed (cleared on done)
|
||||
store.listTasks.mockResolvedValue([
|
||||
makeTask({ id: "HAI-019", column: "done", dependencies: [] }),
|
||||
makeTask({ id: "HAI-020", column: "in-progress", dependencies: ["HAI-019"] }),
|
||||
]);
|
||||
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
||||
|
||||
// Should call `git worktree add`, NOT `git checkout -b`
|
||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
||||
);
|
||||
expect(worktreeAddCall).toBeDefined();
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
||||
);
|
||||
expect(checkoutCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates fresh worktree when task has NO dependencies", async () => {
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([]);
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: [] }));
|
||||
|
||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
||||
);
|
||||
expect(worktreeAddCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT run worktreeInitCommand when reusing a worktree", async () => {
|
||||
const store = createMockStore();
|
||||
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
|
||||
|
||||
store.listTasks.mockResolvedValue([
|
||||
makeTask({
|
||||
id: "HAI-019",
|
||||
column: "in-review",
|
||||
worktree: depWorktreePath,
|
||||
dependencies: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
worktreeInitCommand: "pnpm install",
|
||||
});
|
||||
|
||||
mockedExistsSync.mockImplementation((p: any) => p === depWorktreePath);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
||||
|
||||
// Init command should NOT have been run
|
||||
const initCall = mockedExecSync.mock.calls.find(
|
||||
(call) => call[0] === "pnpm install",
|
||||
);
|
||||
expect(initCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolveDependencyWorktree picks first dependency with existing worktree", async () => {
|
||||
const store = createMockStore();
|
||||
const depAPath = "/tmp/test/.worktrees/HAI-018";
|
||||
const depBPath = "/tmp/test/.worktrees/HAI-019";
|
||||
|
||||
// Two deps: A has no worktree on disk, B does
|
||||
store.listTasks.mockResolvedValue([
|
||||
makeTask({ id: "HAI-018", column: "in-review" as Column, worktree: depAPath, dependencies: [] }),
|
||||
makeTask({ id: "HAI-019", column: "in-review" as Column, worktree: depBPath, dependencies: [] }),
|
||||
]);
|
||||
|
||||
mockedExistsSync.mockImplementation((p: any) => p === depBPath);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-018", "HAI-019"] }));
|
||||
|
||||
// Should reuse HAI-019's worktree (the one that exists)
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depBPath });
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
||||
);
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(checkoutCall![1]).toMatchObject({ cwd: depBPath });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor cleanup — chain-aware", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("does NOT remove worktree if another task still uses it", async () => {
|
||||
const store = createMockStore();
|
||||
mockedFindWorktreeUser.mockResolvedValue("HAI-021");
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Execute a task to register a worktree
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
await executor.execute({
|
||||
id: "HAI-020",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Now cleanup — should skip removal because findWorktreeUser returns "HAI-021"
|
||||
await executor.cleanup("HAI-020");
|
||||
|
||||
const removeCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
|
||||
);
|
||||
expect(removeCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes worktree when no other task uses it", async () => {
|
||||
const store = createMockStore();
|
||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
await executor.execute({
|
||||
id: "HAI-020",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await executor.cleanup("HAI-020");
|
||||
|
||||
const removeCall = mockedExecSync.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
|
||||
);
|
||||
expect(removeCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
import { reviewStep } from "./reviewer.js";
|
||||
@@ -165,6 +166,43 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a dependency task that has an existing worktree directory on disk.
|
||||
* Returns the worktree path if found, null otherwise.
|
||||
* Prefers dependencies whose worktree is still on disk so build caches
|
||||
* (node_modules, target/, dist/) can be reused by dependent tasks.
|
||||
*/
|
||||
private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
|
||||
if (task.dependencies.length === 0) return null;
|
||||
|
||||
for (const depId of task.dependencies) {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
if (
|
||||
dep &&
|
||||
dep.worktree &&
|
||||
(dep.column === "done" || dep.column === "in-review") &&
|
||||
existsSync(dep.worktree)
|
||||
) {
|
||||
return dep.worktree;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an existing worktree directory from a dependency task.
|
||||
* Instead of creating a new worktree with `git worktree add`, this creates
|
||||
* a new branch in the existing worktree via `git checkout -b`. The worktree
|
||||
* directory (and its build caches) are preserved.
|
||||
*/
|
||||
private reuseWorktree(branch: string, worktreePath: string): void {
|
||||
execSync(`git checkout -b "${branch}"`, {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(`[executor] Reused worktree at ${worktreePath}, created branch ${branch}`);
|
||||
}
|
||||
|
||||
async execute(task: Task): Promise<void> {
|
||||
if (this.executing.has(task.id)) return;
|
||||
this.executing.add(task.id);
|
||||
@@ -184,14 +222,34 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a dependency has a reusable worktree (warm cache)
|
||||
const depWorktree = this.resolveDependencyWorktree(task, allTasks);
|
||||
|
||||
// Create or reuse worktree
|
||||
const branchName = `hai/${task.id.toLowerCase()}`;
|
||||
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
|
||||
const isResume = existsSync(worktreePath);
|
||||
this.createWorktree(branchName, worktreePath);
|
||||
let worktreePath: string;
|
||||
let isResume: boolean;
|
||||
let isReuse: boolean;
|
||||
|
||||
if (depWorktree && !task.worktree) {
|
||||
// Reuse dependency worktree for warm build cache
|
||||
worktreePath = depWorktree;
|
||||
isResume = false;
|
||||
isReuse = true;
|
||||
const depTask = allTasks.find((t) => t.worktree === depWorktree);
|
||||
const depId = depTask?.id ?? "unknown";
|
||||
console.log(`[executor] Reusing worktree from ${depId} at ${depWorktree} (warm cache)`);
|
||||
this.reuseWorktree(branchName, worktreePath);
|
||||
} else {
|
||||
worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
|
||||
isResume = existsSync(worktreePath);
|
||||
isReuse = false;
|
||||
this.createWorktree(branchName, worktreePath);
|
||||
}
|
||||
|
||||
this.activeWorktrees.set(task.id, worktreePath);
|
||||
|
||||
if (!isResume) {
|
||||
if (!isResume && !isReuse) {
|
||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
|
||||
|
||||
@@ -210,6 +268,10 @@ export class TaskExecutor {
|
||||
await this.store.logEntry(task.id, `Worktree init command failed: ${message}`);
|
||||
}
|
||||
}
|
||||
} else if (isReuse) {
|
||||
// Update task's worktree field to point to the reused directory
|
||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||
await this.store.logEntry(task.id, `Reusing worktree at ${worktreePath} (warm cache)`);
|
||||
}
|
||||
|
||||
this.options.onStart?.(task, worktreePath);
|
||||
@@ -457,12 +519,26 @@ export class TaskExecutor {
|
||||
console.log(`[executor] Worktree created: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a task's worktree, but only if no other in-progress or todo task
|
||||
* shares the same worktree path (dependency-chain reuse). The branch is
|
||||
* always cleaned up by the merger on a per-task basis.
|
||||
*/
|
||||
async cleanup(taskId: string): Promise<void> {
|
||||
const worktreePath = this.activeWorktrees.get(taskId);
|
||||
if (!worktreePath) return;
|
||||
|
||||
this.activeWorktrees.delete(taskId);
|
||||
|
||||
// Check if another task still needs this worktree
|
||||
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
|
||||
if (otherUser) {
|
||||
console.log(`[executor] Worktree retained for ${taskId} — still needed by ${otherUser}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" });
|
||||
this.activeWorktrees.delete(taskId);
|
||||
console.log(`[executor] Cleaned up worktree for ${taskId}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[executor] Failed to clean up worktree for ${taskId}:`, err.message);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,27 @@ git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route wi
|
||||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||||
Base the message on the ACTUAL work done in the branch commits.`;
|
||||
|
||||
/**
|
||||
* Check if any non-done task (other than `excludeTaskId`) references the given
|
||||
* worktree path. Returns the first matching task ID, or null if the worktree
|
||||
* is safe to remove. Used by both the merger and executor cleanup to avoid
|
||||
* deleting worktrees that are shared across dependent tasks.
|
||||
*/
|
||||
export async function findWorktreeUser(
|
||||
store: TaskStore,
|
||||
worktreePath: string,
|
||||
excludeTaskId: string,
|
||||
): Promise<string | null> {
|
||||
const tasks = await store.listTasks();
|
||||
for (const t of tasks) {
|
||||
if (t.id === excludeTaskId) continue;
|
||||
if (t.worktree === worktreePath && t.column !== "done") {
|
||||
return t.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface MergerOptions {
|
||||
/** Called with agent text output */
|
||||
onAgentText?: (delta: string) => void;
|
||||
@@ -190,18 +211,7 @@ export async function aiMergeTask(
|
||||
session.dispose();
|
||||
}
|
||||
|
||||
// 7. Clean up worktree
|
||||
if (existsSync(worktreePath)) {
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
result.worktreeRemoved = true;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 8. Delete branch
|
||||
// 7. Delete branch (always per-task, regardless of worktree sharing)
|
||||
try {
|
||||
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||
result.branchDeleted = true;
|
||||
@@ -212,6 +222,23 @@ export async function aiMergeTask(
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 8. Clean up worktree — only if no other non-done task still references it
|
||||
if (existsSync(worktreePath)) {
|
||||
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
|
||||
if (otherUser) {
|
||||
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
|
||||
result.worktreeRemoved = false;
|
||||
} else {
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
result.worktreeRemoved = true;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Move task to done
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user