feat(FN-2103): merge fusion/fn-2103
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import type { ExecException } from "node:child_process";
|
import type { ExecException } from "node:child_process";
|
||||||
|
|
||||||
// Route async `exec` (via promisify) through the `execSync` mock so existing
|
// Route async `exec` (via promisify) through the `execSync` mock so existing
|
||||||
@@ -44,7 +44,13 @@ vi.mock("node:fs", () => ({
|
|||||||
rmSync: vi.fn(),
|
rmSync: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
import {
|
||||||
|
WorktreePool,
|
||||||
|
getRegisteredWorktreePaths,
|
||||||
|
scanIdleWorktrees,
|
||||||
|
cleanupOrphanedWorktrees,
|
||||||
|
scanOrphanedBranches,
|
||||||
|
} from "./worktree-pool.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||||
import type { Task, Column } from "@fusion/core";
|
import type { Task, Column } from "@fusion/core";
|
||||||
@@ -54,6 +60,19 @@ const mockedExistsSync = vi.mocked(existsSync);
|
|||||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||||
const mockedRmSync = vi.mocked(rmSync);
|
const mockedRmSync = vi.mocked(rmSync);
|
||||||
|
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("WorktreePool", () => {
|
describe("WorktreePool", () => {
|
||||||
let pool: WorktreePool;
|
let pool: WorktreePool;
|
||||||
|
|
||||||
@@ -210,6 +229,10 @@ describe("WorktreePool", () => {
|
|||||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-001");
|
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-001");
|
||||||
expect(result).toBe("fusion/fn-001");
|
expect(result).toBe("fusion/fn-001");
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): nothing to checkout"),
|
||||||
|
);
|
||||||
|
|
||||||
// Should still run clean and branch creation
|
// Should still run clean and branch creation
|
||||||
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||||
expect(calls).toContain("git clean -fd");
|
expect(calls).toContain("git clean -fd");
|
||||||
@@ -217,6 +240,22 @@ describe("WorktreePool", () => {
|
|||||||
expect(calls).toContain('git checkout -B "fusion/fn-001" main');
|
expect(calls).toContain('git checkout -B "fusion/fn-001" main');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs checkout -- failure at debug level", async () => {
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
if (cmd === "git checkout -- .") {
|
||||||
|
throw new Error("working tree already clean");
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||||
|
|
||||||
|
expect(result).toBe("fusion/fn-042");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): working tree already clean"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses suffixed branch name when original is in use by an active worktree", async () => {
|
it("uses suffixed branch name when original is in use by an active worktree", async () => {
|
||||||
mockedExistsSync.mockImplementation((p) => {
|
mockedExistsSync.mockImplementation((p) => {
|
||||||
// The conflicting worktree exists on disk
|
// The conflicting worktree exists on disk
|
||||||
@@ -366,6 +405,28 @@ describe("WorktreePool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getRegisteredWorktreePaths", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs warning and returns empty set when git worktree list fails", async () => {
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
if (String(cmd) === "git worktree list --porcelain") {
|
||||||
|
throw new Error("git unavailable");
|
||||||
|
}
|
||||||
|
return Buffer.from("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const registered = await getRegisteredWorktreePaths("/root");
|
||||||
|
|
||||||
|
expect(registered).toEqual(new Set());
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] Failed to list registered worktrees: git unavailable"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Helper for mock store ─────────────────────────────────────────────
|
// ── Helper for mock store ─────────────────────────────────────────────
|
||||||
|
|
||||||
function makeTask(id: string, column: Column, worktree?: string): Task {
|
function makeTask(id: string, column: Column, worktree?: string): Task {
|
||||||
@@ -496,6 +557,9 @@ describe("scanIdleWorktrees", () => {
|
|||||||
|
|
||||||
const idle = await scanIdleWorktrees("/root", store);
|
const idle = await scanIdleWorktrees("/root", store);
|
||||||
expect(idle).toEqual([]);
|
expect(idle).toEqual([]);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] Failed to read .worktrees/ directory: Permission denied"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not return unregistered directories for pool rehydration", async () => {
|
it("does not return unregistered directories for pool rehydration", async () => {
|
||||||
@@ -614,6 +678,26 @@ describe("cleanupOrphanedWorktrees", () => {
|
|||||||
expect(removeCalls).toHaveLength(0);
|
expect(removeCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs warning when readdirSync fails for cleanup scan", async () => {
|
||||||
|
let readdirCalls = 0;
|
||||||
|
mockedReaddirSync.mockImplementation(() => {
|
||||||
|
readdirCalls += 1;
|
||||||
|
if (readdirCalls === 1) {
|
||||||
|
return [] as any;
|
||||||
|
}
|
||||||
|
throw new Error("cleanup permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore([]);
|
||||||
|
|
||||||
|
const cleaned = await cleanupOrphanedWorktrees("/root", store);
|
||||||
|
|
||||||
|
expect(cleaned).toBe(0);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] Failed to read .worktrees/ directory for cleanup: cleanup permission denied"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 0 when all worktrees are assigned to active tasks", async () => {
|
it("returns 0 when all worktrees are assigned to active tasks", async () => {
|
||||||
mockedReaddirSync.mockReturnValue([
|
mockedReaddirSync.mockReturnValue([
|
||||||
makeDirEntry("active-1"),
|
makeDirEntry("active-1"),
|
||||||
@@ -757,6 +841,9 @@ describe("scanOrphanedBranches", () => {
|
|||||||
|
|
||||||
const orphaned = await scanOrphanedBranches("/root", store);
|
const orphaned = await scanOrphanedBranches("/root", store);
|
||||||
expect(orphaned).toEqual([]);
|
expect(orphaned).toEqual([]);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("[worktree-pool] Failed to list fusion/* branches: not a git repo"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array when no fusion/* branches exist", async () => {
|
it("returns empty array when no fusion/* branches exist", async () => {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return paths;
|
return paths;
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
worktreePoolLog.warn(`Failed to list registered worktrees: ${errorMessage}`);
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,9 @@ export class WorktreePool {
|
|||||||
// Clean tracked modifications
|
// Clean tracked modifications
|
||||||
try {
|
try {
|
||||||
await execAsync("git checkout -- .", { cwd: worktreePath });
|
await execAsync("git checkout -- .", { cwd: worktreePath });
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
worktreePoolLog.log(`git checkout -- . failed (may be clean): ${errorMessage}`);
|
||||||
// May fail if worktree is already clean — that's fine
|
// May fail if worktree is already clean — that's fine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +264,9 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
|
|||||||
dirs = entries
|
dirs = entries
|
||||||
.filter((e) => e.isDirectory())
|
.filter((e) => e.isDirectory())
|
||||||
.map((e) => join(worktreesDir, e.name));
|
.map((e) => join(worktreesDir, e.name));
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
worktreePoolLog.warn(`Failed to read .worktrees/ directory: ${errorMessage}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +322,9 @@ export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore
|
|||||||
dirs = readdirSync(worktreesDir, { withFileTypes: true })
|
dirs = readdirSync(worktreesDir, { withFileTypes: true })
|
||||||
.filter((e) => e.isDirectory())
|
.filter((e) => e.isDirectory())
|
||||||
.map((e) => join(worktreesDir, e.name));
|
.map((e) => join(worktreesDir, e.name));
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
worktreePoolLog.warn(`Failed to read .worktrees/ directory for cleanup: ${errorMessage}`);
|
||||||
dirs = [];
|
dirs = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +385,9 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim().replace(/^\*?\s*/, ""))
|
.map((line) => line.trim().replace(/^\*?\s*/, ""))
|
||||||
.filter((line) => line.startsWith("fusion/"));
|
.filter((line) => line.startsWith("fusion/"));
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
worktreePoolLog.warn(`Failed to list fusion/* branches: ${errorMessage}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user