Harden dashboard worktree shutdown handling

This commit is contained in:
gsxdsm
2026-04-11 20:28:34 -07:00
parent 9970c9ae76
commit 67d2222202
9 changed files with 449 additions and 36 deletions

View File

@@ -543,6 +543,21 @@ describe("TaskExecutor worktree naming", () => {
it("reuses stored worktree path for resumed tasks", async () => {
const existingPath = "/tmp/test/.worktrees/calm-river";
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /tmp/test",
"HEAD abc123",
"branch refs/heads/main",
"",
`worktree ${existingPath}`,
"HEAD def456",
"branch refs/heads/fusion/fn-031",
"",
].join("\n") as any;
}
return Buffer.from("");
});
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
@@ -553,6 +568,29 @@ describe("TaskExecutor worktree naming", () => {
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
});
it("does not reuse a stored worktree path that is not registered", async () => {
const stalePath = "/tmp/test/.worktrees/broken-wt";
mockedExistsSync.mockImplementation((path) => String(path).startsWith(stalePath));
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return "worktree /tmp/test\nHEAD abc123\nbranch refs/heads/main\n" as any;
}
return Buffer.from("");
});
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask("FN-032", stalePath));
expect(store.updateTask).toHaveBeenCalledWith("FN-032", { worktree: null, branch: null });
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
});
describe("worktreeNaming setting", () => {
it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => {
const store = createMockStore();

View File

@@ -15,7 +15,7 @@ import { createKbAgent, describeModel, promptWithFallback, compactSessionContext
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { AuthStorage, ModelRegistry, SessionManager, getAgentDir, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import type { WorktreePool } from "./worktree-pool.js";
import { isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
import { executorLog, reviewerLog } from "./logger.js";
import { TokenCapDetector } from "./token-cap-detector.js";
@@ -903,6 +903,20 @@ export class TaskExecutor {
// Resolve the base branch — set by the scheduler when a dep is in-review
const baseBranch = task.baseBranch || null;
if (task.worktree && isResume && !isUsableTaskWorktree(this.rootDir, worktreePath)) {
const invalidWorktreePath = worktreePath;
executorLog.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${invalidWorktreePath}`);
await this.store.logEntry(
task.id,
`Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead`,
invalidWorktreePath,
this.currentRunContext,
);
await this.store.updateTask(task.id, { worktree: null, branch: null });
worktreePath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
isResume = existsSync(worktreePath);
}
if (!isResume) {
// Try acquiring a warm worktree from the pool
@@ -3258,16 +3272,7 @@ and show an appropriate message to the user.\`
* Check if a path is registered as a git worktree.
*/
private isRegisteredWorktree(path: string): boolean {
try {
const output = execSync("git worktree list --porcelain", {
cwd: this.rootDir,
encoding: "utf-8",
stdio: "pipe",
});
return output.includes(path);
} catch {
return false;
}
return isRegisteredGitWorktree(this.rootDir, path);
}
/**

View File

@@ -17,6 +17,22 @@ const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create"
const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
const setFallbackResolverMock = vi.fn();
const reloadMock = vi.fn(async () => {});
const execSyncMock = vi.fn(() => "");
const existsSyncMock = vi.fn(() => false);
const readFileSyncMock = vi.fn(() => "{}");
vi.mock("node:child_process", () => ({
execSync: execSyncMock,
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
};
});
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
@@ -279,6 +295,9 @@ describe("worktree path boundary helpers", () => {
describe("createKbAgent", () => {
beforeEach(() => {
vi.clearAllMocks();
execSyncMock.mockReturnValue("");
existsSyncMock.mockReturnValue(false);
readFileSyncMock.mockReturnValue("{}");
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createAgentSessionMock.mockResolvedValue({
session: {
@@ -290,6 +309,53 @@ describe("createKbAgent", () => {
});
});
it("refuses to start a coding agent in an unregistered worktree", async () => {
existsSyncMock.mockImplementation((path) => {
const value = String(path);
return value === "/project/.worktrees/fn-001" ||
value === "/project/.worktrees/fn-001/.git" ||
value === "/project/.worktrees/fn-001/package.json";
});
execSyncMock.mockReturnValue("worktree /project\nHEAD abc123\nbranch refs/heads/main\n");
const { createKbAgent } = await import("./pi.js");
await expect(createKbAgent({
cwd: "/project/.worktrees/fn-001",
systemPrompt: "test",
tools: "coding",
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
})).rejects.toThrow("Refusing to start coding agent in unregistered git worktree");
expect(createAgentSessionMock).not.toHaveBeenCalled();
});
it("allows a coding agent in a registered complete worktree", async () => {
existsSyncMock.mockImplementation((path) => {
const value = String(path);
return value === "/project/.worktrees/fn-001" ||
value === "/project/.worktrees/fn-001/.git" ||
value === "/project/.worktrees/fn-001/package.json";
});
execSyncMock.mockReturnValue(
"worktree /project\nHEAD abc123\nbranch refs/heads/main\n\n" +
"worktree /project/.worktrees/fn-001\nHEAD def456\nbranch refs/heads/fusion/fn-001\n",
);
const { createKbAgent } = await import("./pi.js");
await createKbAgent({
cwd: "/project/.worktrees/fn-001",
systemPrompt: "test",
tools: "coding",
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
});
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
});
it("registers extension providers before resolving configured models", async () => {
packageManagerResolveMock.mockResolvedValueOnce({
extensions: [{ enabled: true, path: "/extensions/zai-provider" }],

View File

@@ -6,6 +6,7 @@
*/
import { existsSync, readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, relative, isAbsolute, resolve } from "node:path";
import {
AuthStorage,
@@ -273,6 +274,34 @@ function getProjectRootFromWorktree(cwd: string): string | null {
return null;
}
function isRegisteredGitWorktree(projectRoot: string, worktreePath: string): boolean {
try {
const output = String(execSync("git worktree list --porcelain", {
cwd: projectRoot,
encoding: "utf-8",
stdio: "pipe",
}));
const resolvedWorktree = resolve(worktreePath);
return output.split("\n").some((line) =>
line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree
);
} catch {
return false;
}
}
function assertValidWorktreeSession(cwd: string, projectRoot: string): void {
if (!existsSync(cwd)) {
throw new Error(`Refusing to start coding agent in missing worktree: ${cwd}`);
}
if (!existsSync(join(cwd, ".git")) || !existsSync(join(cwd, "package.json"))) {
throw new Error(`Refusing to start coding agent in incomplete worktree: ${cwd}`);
}
if (!isRegisteredGitWorktree(projectRoot, cwd)) {
throw new Error(`Refusing to start coding agent in unregistered git worktree: ${cwd}`);
}
}
/**
* Check if a path is allowed to be accessed from a worktree session.
* Rules:
@@ -398,6 +427,9 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
// Detect if this is a worktree session and apply path boundaries
const worktreePath = options.cwd;
const projectRoot = getProjectRootFromWorktree(worktreePath);
if (projectRoot) {
assertValidWorktreeSession(worktreePath, projectRoot);
}
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
// Compaction is explicitly enabled to prevent context-window overflow during

View File

@@ -204,7 +204,37 @@ function createAgentWithTaskDone() {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true); // Default: worktrees exist (resume scenario)
mockedExecSync.mockReturnValue(Buffer.from(""));
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /tmp/test",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /tmp/wt/KB-010",
"HEAD def456",
"branch refs/heads/fusion/fn-010",
"",
"worktree /tmp/wt/FN-963",
"HEAD def456",
"branch refs/heads/fusion/fn-963",
"",
"worktree /root/.worktrees/active-wt",
"HEAD def456",
"branch refs/heads/fusion/active-wt",
"",
"worktree /root/.worktrees/review-wt",
"HEAD def456",
"branch refs/heads/fusion/review-wt",
"",
"worktree /root/.worktrees/idle-wt",
"HEAD def456",
"branch refs/heads/fusion/idle-wt",
"",
].join("\n") as any;
}
return Buffer.from("");
});
});
// ── Step 2: In-progress task resume tests ─────────────────────────────────
@@ -874,6 +904,26 @@ function makeDirEntry(name: string) {
return { name, isDirectory: () => true } as any;
}
function mockRegisteredWorktrees(rootDir: string, names: string[]) {
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
`worktree ${rootDir}`,
"HEAD abc123",
"branch refs/heads/main",
"",
...names.flatMap((name) => [
`worktree ${rootDir}/.worktrees/${name}`,
"HEAD def456",
`branch refs/heads/fusion/${name}`,
"",
]),
].join("\n") as any;
}
return Buffer.from("");
});
}
describe("Worktree pool restart with recycleWorktrees=true", () => {
it("pool is rehydrated with idle worktrees from disk", async () => {
mockedReaddirSync.mockReturnValue([
@@ -882,6 +932,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
makeDirEntry("bold-eagle"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockRegisteredWorktrees("/root", ["swift-falcon", "calm-river", "bold-eagle"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([
@@ -908,6 +959,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("idle-wt"),
] as any);
mockRegisteredWorktrees("/root", ["idle-wt"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
@@ -958,6 +1010,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
makeDirEntry("idle-wt"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockRegisteredWorktrees("/root", ["active-wt", "idle-wt"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([
@@ -979,6 +1032,7 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
makeDirEntry("review-wt"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockRegisteredWorktrees("/root", ["review-wt"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([
@@ -1001,7 +1055,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
makeDirEntry("orphan-2"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockReturnValue(Buffer.from(""));
mockRegisteredWorktrees("/root", ["orphan-1", "orphan-2"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
@@ -1021,7 +1075,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
makeDirEntry("orphan-wt"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockReturnValue(Buffer.from(""));
mockRegisteredWorktrees("/root", ["active-wt", "orphan-wt"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([
@@ -1044,6 +1098,7 @@ describe("Worktree cleanup on restart with recycleWorktrees=false", () => {
makeDirEntry("review-wt"),
] as any);
mockedExistsSync.mockReturnValue(true);
mockRegisteredWorktrees("/root", ["review-wt"]);
const store = createMockStore();
store.listTasks.mockResolvedValue([

View File

@@ -7,16 +7,18 @@ vi.mock("node:child_process", () => ({
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readdirSync: vi.fn().mockReturnValue([]),
rmSync: vi.fn(),
}));
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, rmSync } from "node:fs";
import type { Task, Column } from "@fusion/core";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);
const mockedReaddirSync = vi.mocked(readdirSync);
const mockedRmSync = vi.mocked(rmSync);
describe("WorktreePool", () => {
let pool: WorktreePool;
@@ -367,12 +369,33 @@ function makeDirEntry(name: string) {
return { name, isDirectory: () => true } as any;
}
function mockRegisteredWorktrees(rootDir: string, names: string[]) {
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
`worktree ${rootDir}`,
"HEAD abc123",
"branch refs/heads/main",
"",
...names.flatMap((name) => [
`worktree ${rootDir}/.worktrees/${name}`,
"HEAD def456",
`branch refs/heads/fusion/${name}`,
"",
]),
].join("\n") as any;
}
return Buffer.from("");
});
}
// ── scanIdleWorktrees tests ───────────────────────────────────────────
describe("scanIdleWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockRegisteredWorktrees("/root", []);
});
it("correctly identifies idle vs active worktrees", async () => {
@@ -381,6 +404,7 @@ describe("scanIdleWorktrees", () => {
makeDirEntry("calm-river"),
makeDirEntry("bold-eagle"),
] as any);
mockRegisteredWorktrees("/root", ["swift-falcon", "calm-river", "bold-eagle"]);
const store = createMockStore([
makeTask("FN-001", "in-progress", "/root/.worktrees/swift-falcon"),
@@ -417,6 +441,7 @@ describe("scanIdleWorktrees", () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("review-wt"),
] as any);
mockRegisteredWorktrees("/root", ["review-wt"]);
const store = createMockStore([
makeTask("FN-010", "in-review", "/root/.worktrees/review-wt"),
@@ -431,6 +456,7 @@ describe("scanIdleWorktrees", () => {
makeDirEntry("wt-1"),
makeDirEntry("wt-2"),
] as any);
mockRegisteredWorktrees("/root", ["wt-1", "wt-2"]);
const store = createMockStore([]);
@@ -449,6 +475,21 @@ describe("scanIdleWorktrees", () => {
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toEqual([]);
});
it("does not return unregistered directories for pool rehydration", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("registered-wt"),
makeDirEntry("broken-wt"),
] as any);
mockRegisteredWorktrees("/root", ["registered-wt"]);
const store = createMockStore([
makeTask("FN-001", "in-progress", "/root/.worktrees/broken-wt"),
]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toEqual(["/root/.worktrees/registered-wt"]);
});
});
// ── cleanupOrphanedWorktrees tests ────────────────────────────────────
@@ -457,7 +498,7 @@ describe("cleanupOrphanedWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockReturnValue(Buffer.from(""));
mockRegisteredWorktrees("/root", []);
});
it("removes worktrees not assigned to any active task", async () => {
@@ -465,6 +506,7 @@ describe("cleanupOrphanedWorktrees", () => {
makeDirEntry("orphan-1"),
makeDirEntry("orphan-2"),
] as any);
mockRegisteredWorktrees("/root", ["orphan-1", "orphan-2"]);
const store = createMockStore([]);
@@ -484,6 +526,7 @@ describe("cleanupOrphanedWorktrees", () => {
makeDirEntry("active-wt"),
makeDirEntry("orphan-wt"),
] as any);
mockRegisteredWorktrees("/root", ["active-wt", "orphan-wt"]);
const store = createMockStore([
makeTask("FN-001", "in-progress", "/root/.worktrees/active-wt"),
@@ -507,6 +550,22 @@ describe("cleanupOrphanedWorktrees", () => {
] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /root",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /root/.worktrees/fail-wt",
"HEAD def456",
"branch refs/heads/fusion/fail-wt",
"",
"worktree /root/.worktrees/ok-wt",
"HEAD def456",
"branch refs/heads/fusion/ok-wt",
"",
].join("\n") as any;
}
if (typeof cmd === "string" && cmd.includes("fail-wt")) {
throw new Error("worktree locked");
}
@@ -527,7 +586,10 @@ describe("cleanupOrphanedWorktrees", () => {
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
const removeCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
);
expect(removeCalls).toHaveLength(0);
});
it("returns 0 when all worktrees are assigned to active tasks", async () => {
@@ -535,6 +597,7 @@ describe("cleanupOrphanedWorktrees", () => {
makeDirEntry("active-1"),
makeDirEntry("active-2"),
] as any);
mockRegisteredWorktrees("/root", ["active-1", "active-2"]);
const store = createMockStore([
makeTask("FN-001", "in-progress", "/root/.worktrees/active-1"),
@@ -543,7 +606,29 @@ describe("cleanupOrphanedWorktrees", () => {
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
const removeCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
);
expect(removeCalls).toHaveLength(0);
});
it("removes unregistered directories even when stale active task metadata references them", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("broken-wt"),
] as any);
mockRegisteredWorktrees("/root", []);
const store = createMockStore([
makeTask("FN-001", "in-progress", "/root/.worktrees/broken-wt"),
]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", {
recursive: true,
force: true,
});
});
});

View File

@@ -1,9 +1,50 @@
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core";
import { worktreePoolLog } from "./logger.js";
export function getRegisteredWorktreePaths(rootDir: string): Set<string> {
try {
const output = String(execSync("git worktree list --porcelain", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}));
const paths = new Set<string>();
for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) {
paths.add(resolve(line.slice("worktree ".length)));
}
}
return paths;
} catch {
return new Set();
}
}
export function isRegisteredGitWorktree(rootDir: string, worktreePath: string): boolean {
return getRegisteredWorktreePaths(rootDir).has(resolve(worktreePath));
}
export function hasRequiredWorktreeFiles(worktreePath: string): boolean {
return existsSync(join(worktreePath, ".git")) && existsSync(join(worktreePath, "package.json"));
}
export function isUsableTaskWorktree(rootDir: string, worktreePath: string): boolean {
return existsSync(worktreePath) &&
isRegisteredGitWorktree(rootDir, worktreePath) &&
hasRequiredWorktreeFiles(worktreePath);
}
function isInsideWorktreesDir(rootDir: string, worktreePath: string): boolean {
const worktreesDir = resolve(rootDir, ".worktrees");
const target = resolve(worktreePath);
const rel = relative(worktreesDir, target);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
/**
* A pool of idle git worktrees that can be recycled across tasks.
*
@@ -220,17 +261,24 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
return [];
}
const registeredWorktrees = getRegisteredWorktreePaths(rootDir);
const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir)));
// Find worktree paths assigned to non-done tasks (active worktrees)
const tasks = await store.listTasks();
const tasks = await store.listTasks({ slim: true, includeArchived: false });
const activeWorktrees = new Set<string>();
for (const task of tasks) {
if (task.worktree && task.column !== "done") {
activeWorktrees.add(task.worktree);
if (task.worktree && task.column !== "done" && registeredWorktrees.has(resolve(task.worktree))) {
activeWorktrees.add(resolve(task.worktree));
} else if (task.worktree && task.column !== "done") {
worktreePoolLog.log(`Ignoring task ${task.id} worktree metadata because it is not a registered git worktree: ${task.worktree}`);
}
}
// Return worktrees on disk that are NOT active
return dirs.filter((dir) => !activeWorktrees.has(dir));
// Return registered worktrees on disk that are NOT active. Unregistered
// directories are intentionally excluded here so recycle mode never adds a
// broken directory to the warm pool; cleanup handles those separately.
return registeredDirs.filter((dir) => !activeWorktrees.has(resolve(dir)));
}
/**
@@ -247,15 +295,42 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
* @returns Number of worktrees cleaned up
*/
export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore): Promise<number> {
const worktreesDir = join(rootDir, ".worktrees");
if (!existsSync(worktreesDir)) {
return 0;
}
const orphaned = await scanIdleWorktrees(rootDir, store);
const registeredWorktrees = getRegisteredWorktreePaths(rootDir);
let dirs: string[] = [];
if (existsSync(worktreesDir)) {
try {
dirs = readdirSync(worktreesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => join(worktreesDir, e.name));
} catch {
dirs = [];
}
}
const unregistered = dirs.filter((dir) => !registeredWorktrees.has(resolve(dir)));
const candidates = [...orphaned, ...unregistered];
let cleaned = 0;
for (const worktreePath of orphaned) {
for (const worktreePath of candidates) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
if (registeredWorktrees.has(resolve(worktreePath))) {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
} else {
if (!isInsideWorktreesDir(rootDir, worktreePath)) {
throw new Error(`Refusing to remove path outside .worktrees: ${worktreePath}`);
}
rmSync(worktreePath, { recursive: true, force: true });
}
worktreePoolLog.log(`Cleaned up orphaned worktree: ${worktreePath}`);
cleaned++;
} catch (err: any) {