feat(FN-2103): merge fusion/fn-2103

This commit is contained in:
gsxdsm
2026-04-18 22:37:20 -07:00
parent 614f9b4ddd
commit 18d2634dbe
2 changed files with 104 additions and 7 deletions

View File

@@ -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 () => {

View File

@@ -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 [];
} }