- Add rehydrate() method and scanIdleWorktrees() to restore idle worktrees from disk on startup - Add cleanupOrphanedWorktrees() to remove stale worktrees when recycling is disabled - Wire startup rehydrate/cleanup into dashboard command based on recycleWorktrees setting - Export new functions from @kb/engine and update dashboard imports - Add unit tests for rehydrate/scan/cleanup and integration tests for restart resilience
381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
execSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
readdirSync: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
|
|
import { execSync } from "node:child_process";
|
|
import { existsSync, readdirSync } from "node:fs";
|
|
import type { Task, Column } from "@kb/core";
|
|
|
|
const mockedExecSync = vi.mocked(execSync);
|
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
|
|
|
describe("WorktreePool", () => {
|
|
let pool: WorktreePool;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
pool = new WorktreePool();
|
|
});
|
|
|
|
describe("acquire", () => {
|
|
it("returns null when pool is empty", () => {
|
|
expect(pool.acquire()).toBeNull();
|
|
});
|
|
|
|
it("returns a released path on acquire", () => {
|
|
pool.release("/tmp/worktree-1");
|
|
const result = pool.acquire();
|
|
expect(result).toBe("/tmp/worktree-1");
|
|
});
|
|
|
|
it("prunes entries where directory no longer exists on disk", () => {
|
|
pool.release("/tmp/stale-worktree");
|
|
pool.release("/tmp/good-worktree");
|
|
// First path doesn't exist, second does
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/good-worktree");
|
|
|
|
const result = pool.acquire();
|
|
expect(result).toBe("/tmp/good-worktree");
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
|
|
it("returns null when all entries are stale", () => {
|
|
pool.release("/tmp/stale-1");
|
|
pool.release("/tmp/stale-2");
|
|
mockedExistsSync.mockReturnValue(false);
|
|
|
|
expect(pool.acquire()).toBeNull();
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("release", () => {
|
|
it("adds a path to the pool", () => {
|
|
pool.release("/tmp/wt-1");
|
|
expect(pool.size).toBe(1);
|
|
expect(pool.has("/tmp/wt-1")).toBe(true);
|
|
});
|
|
|
|
it("does not duplicate on double release", () => {
|
|
pool.release("/tmp/wt-1");
|
|
pool.release("/tmp/wt-1");
|
|
expect(pool.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("size", () => {
|
|
it("reflects correct count after operations", () => {
|
|
expect(pool.size).toBe(0);
|
|
pool.release("/tmp/a");
|
|
pool.release("/tmp/b");
|
|
expect(pool.size).toBe(2);
|
|
pool.acquire();
|
|
expect(pool.size).toBe(1);
|
|
pool.acquire();
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("has", () => {
|
|
it("returns false for unknown paths", () => {
|
|
expect(pool.has("/tmp/unknown")).toBe(false);
|
|
});
|
|
|
|
it("returns true for released paths", () => {
|
|
pool.release("/tmp/wt");
|
|
expect(pool.has("/tmp/wt")).toBe(true);
|
|
});
|
|
|
|
it("returns false after path is acquired", () => {
|
|
pool.release("/tmp/wt");
|
|
pool.acquire();
|
|
expect(pool.has("/tmp/wt")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("drain", () => {
|
|
it("empties the pool and returns all paths", () => {
|
|
pool.release("/tmp/a");
|
|
pool.release("/tmp/b");
|
|
pool.release("/tmp/c");
|
|
const paths = pool.drain();
|
|
expect(paths).toHaveLength(3);
|
|
expect(paths).toContain("/tmp/a");
|
|
expect(paths).toContain("/tmp/b");
|
|
expect(paths).toContain("/tmp/c");
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
|
|
it("returns empty array when pool is empty", () => {
|
|
expect(pool.drain()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("prepareForTask", () => {
|
|
it("cleans dirty working tree before checkout", () => {
|
|
pool.prepareForTask("/tmp/wt", "kb/kb-042");
|
|
|
|
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
|
expect(calls).toContain("git checkout -- .");
|
|
expect(calls).toContain("git clean -fd");
|
|
});
|
|
|
|
it("creates branch from main with force-reset", () => {
|
|
pool.prepareForTask("/tmp/wt", "kb/kb-042");
|
|
|
|
const checkoutCall = mockedExecSync.mock.calls.find(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
|
|
);
|
|
expect(checkoutCall).toBeDefined();
|
|
expect(checkoutCall![0]).toBe('git checkout -B "kb/kb-042" main');
|
|
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
|
|
});
|
|
|
|
it("runs all commands in the correct worktree directory", () => {
|
|
pool.prepareForTask("/tmp/my-worktree", "kb/kb-099");
|
|
|
|
for (const call of mockedExecSync.mock.calls) {
|
|
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
|
|
}
|
|
});
|
|
|
|
it("tolerates git checkout -- . failure (already clean)", () => {
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (cmd === "git checkout -- .") throw new Error("nothing to checkout");
|
|
return Buffer.from("");
|
|
});
|
|
|
|
// Should not throw
|
|
expect(() => pool.prepareForTask("/tmp/wt", "kb/kb-001")).not.toThrow();
|
|
|
|
// Should still run clean and branch creation
|
|
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
|
expect(calls).toContain("git clean -fd");
|
|
expect(calls).toContain('git checkout -B "kb/kb-001" main');
|
|
});
|
|
});
|
|
|
|
describe("rehydrate", () => {
|
|
it("loads paths into the idle set", () => {
|
|
mockedExistsSync.mockReturnValue(true);
|
|
pool.rehydrate(["/tmp/wt-1", "/tmp/wt-2", "/tmp/wt-3"]);
|
|
expect(pool.size).toBe(3);
|
|
expect(pool.has("/tmp/wt-1")).toBe(true);
|
|
expect(pool.has("/tmp/wt-2")).toBe(true);
|
|
expect(pool.has("/tmp/wt-3")).toBe(true);
|
|
});
|
|
|
|
it("skips paths that don't exist on disk", () => {
|
|
mockedExistsSync.mockImplementation((p) => p === "/tmp/good-wt");
|
|
pool.rehydrate(["/tmp/good-wt", "/tmp/gone-wt"]);
|
|
expect(pool.size).toBe(1);
|
|
expect(pool.has("/tmp/good-wt")).toBe(true);
|
|
expect(pool.has("/tmp/gone-wt")).toBe(false);
|
|
});
|
|
|
|
it("handles empty array", () => {
|
|
pool.rehydrate([]);
|
|
expect(pool.size).toBe(0);
|
|
});
|
|
|
|
it("does not duplicate entries already in the pool", () => {
|
|
mockedExistsSync.mockReturnValue(true);
|
|
pool.release("/tmp/existing");
|
|
pool.rehydrate(["/tmp/existing", "/tmp/new"]);
|
|
expect(pool.size).toBe(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Helper for mock store ─────────────────────────────────────────────
|
|
|
|
function makeTask(id: string, column: Column, worktree?: string): Task {
|
|
return {
|
|
id,
|
|
title: `Task ${id}`,
|
|
description: `Description for ${id}`,
|
|
column,
|
|
dependencies: [],
|
|
worktree,
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function createMockStore(tasks: Task[] = []) {
|
|
return {
|
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
|
} as any;
|
|
}
|
|
|
|
function makeDirEntry(name: string) {
|
|
return { name, isDirectory: () => true } as any;
|
|
}
|
|
|
|
// ── scanIdleWorktrees tests ───────────────────────────────────────────
|
|
|
|
describe("scanIdleWorktrees", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
it("correctly identifies idle vs active worktrees", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("swift-falcon"),
|
|
makeDirEntry("calm-river"),
|
|
makeDirEntry("bold-eagle"),
|
|
] as any);
|
|
|
|
const store = createMockStore([
|
|
makeTask("KB-001", "in-progress", "/root/.worktrees/swift-falcon"),
|
|
makeTask("KB-002", "done", "/root/.worktrees/calm-river"),
|
|
]);
|
|
|
|
const idle = await scanIdleWorktrees("/root", store);
|
|
|
|
// swift-falcon is assigned to in-progress task → NOT idle
|
|
// calm-river is assigned to done task → idle (done tasks don't count)
|
|
// bold-eagle is not assigned at all → idle
|
|
expect(idle).toContain("/root/.worktrees/calm-river");
|
|
expect(idle).toContain("/root/.worktrees/bold-eagle");
|
|
expect(idle).not.toContain("/root/.worktrees/swift-falcon");
|
|
});
|
|
|
|
it("handles empty .worktrees/ directory", async () => {
|
|
mockedReaddirSync.mockReturnValue([] as any);
|
|
const store = createMockStore([]);
|
|
|
|
const idle = await scanIdleWorktrees("/root", store);
|
|
expect(idle).toEqual([]);
|
|
});
|
|
|
|
it("handles missing .worktrees/ directory", async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore([]);
|
|
|
|
const idle = await scanIdleWorktrees("/root", store);
|
|
expect(idle).toEqual([]);
|
|
});
|
|
|
|
it("treats in-review tasks as active (worktree preserved)", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("review-wt"),
|
|
] as any);
|
|
|
|
const store = createMockStore([
|
|
makeTask("KB-010", "in-review", "/root/.worktrees/review-wt"),
|
|
]);
|
|
|
|
const idle = await scanIdleWorktrees("/root", store);
|
|
expect(idle).not.toContain("/root/.worktrees/review-wt");
|
|
});
|
|
|
|
it("returns all worktrees when no tasks exist", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("wt-1"),
|
|
makeDirEntry("wt-2"),
|
|
] as any);
|
|
|
|
const store = createMockStore([]);
|
|
|
|
const idle = await scanIdleWorktrees("/root", store);
|
|
expect(idle).toHaveLength(2);
|
|
expect(idle).toContain("/root/.worktrees/wt-1");
|
|
expect(idle).toContain("/root/.worktrees/wt-2");
|
|
});
|
|
});
|
|
|
|
// ── cleanupOrphanedWorktrees tests ────────────────────────────────────
|
|
|
|
describe("cleanupOrphanedWorktrees", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
mockedExecSync.mockReturnValue(Buffer.from(""));
|
|
});
|
|
|
|
it("removes worktrees not assigned to any active task", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("orphan-1"),
|
|
makeDirEntry("orphan-2"),
|
|
] as any);
|
|
|
|
const store = createMockStore([]);
|
|
|
|
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
|
|
|
expect(cleaned).toBe(2);
|
|
const removeCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
|
);
|
|
expect(removeCalls).toHaveLength(2);
|
|
expect(removeCalls[0][0]).toContain("/root/.worktrees/orphan-1");
|
|
expect(removeCalls[1][0]).toContain("/root/.worktrees/orphan-2");
|
|
});
|
|
|
|
it("preserves worktrees assigned to in-progress/in-review tasks", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("active-wt"),
|
|
makeDirEntry("orphan-wt"),
|
|
] as any);
|
|
|
|
const store = createMockStore([
|
|
makeTask("KB-001", "in-progress", "/root/.worktrees/active-wt"),
|
|
]);
|
|
|
|
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
|
|
|
expect(cleaned).toBe(1);
|
|
const removeCalls = mockedExecSync.mock.calls.filter(
|
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
|
);
|
|
expect(removeCalls).toHaveLength(1);
|
|
expect(removeCalls[0][0]).toContain("orphan-wt");
|
|
expect(removeCalls[0][0]).not.toContain("active-wt");
|
|
});
|
|
|
|
it("handles git worktree remove failures gracefully (non-fatal)", async () => {
|
|
mockedReaddirSync.mockReturnValue([
|
|
makeDirEntry("fail-wt"),
|
|
makeDirEntry("ok-wt"),
|
|
] as any);
|
|
|
|
mockedExecSync.mockImplementation((cmd: any) => {
|
|
if (typeof cmd === "string" && cmd.includes("fail-wt")) {
|
|
throw new Error("worktree locked");
|
|
}
|
|
return Buffer.from("");
|
|
});
|
|
|
|
const store = createMockStore([]);
|
|
|
|
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
|
|
|
// Only 1 cleaned (the other failed), but no throw
|
|
expect(cleaned).toBe(1);
|
|
});
|
|
|
|
it("no-ops when .worktrees/ doesn't exist", async () => {
|
|
mockedExistsSync.mockReturnValue(false);
|
|
const store = createMockStore([]);
|
|
|
|
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
|
expect(cleaned).toBe(0);
|
|
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|