feat(FN-946): add orphaned branch scanning and automatic cleanup
- Add scanOrphanedBranches utility to worktree-pool for detecting fusion/* branches with no matching task - Add cleanupOrphanedBranches to SelfHealingManager with dry-run support and task re-registration - Wire branch cleanup into deleteTask and archiveTask so branches are removed when tasks are deleted or archived - Add comprehensive tests for scanning, cleanup, and integration with delete/archive flows
This commit is contained in:
@@ -9,7 +9,7 @@ vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
|
||||
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
@@ -546,3 +546,147 @@ describe("cleanupOrphanedWorktrees", () => {
|
||||
expect(mockedExecSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── scanOrphanedBranches tests ────────────────────────────────────────
|
||||
|
||||
describe("scanOrphanedBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: return empty string (no branches)
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies branches not associated with any active task", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-progress"),
|
||||
makeTask("FN-002", "todo"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
// FN-001 (in-progress) and FN-002 (todo) are active → not orphaned
|
||||
// FN-003 has no task → orphaned
|
||||
expect(orphaned).toEqual(["fusion/fn-003"]);
|
||||
});
|
||||
|
||||
it("excludes in-review and done tasks (merger manages those)", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "in-review"),
|
||||
makeTask("FN-002", "done"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
// in-review and done tasks are excluded → their branches are orphaned
|
||||
// FN-003 also has no task → orphaned
|
||||
expect(orphaned).toContain("fusion/fn-001");
|
||||
expect(orphaned).toContain("fusion/fn-002");
|
||||
expect(orphaned).toContain("fusion/fn-003");
|
||||
});
|
||||
|
||||
it("excludes archived tasks", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([
|
||||
makeTask("FN-001", "archived"),
|
||||
]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
// Archived task branch is orphaned
|
||||
expect(orphaned).toEqual(["fusion/fn-001"]);
|
||||
});
|
||||
|
||||
it("uses task.branch field when set", async () => {
|
||||
const task = makeTask("FN-001", "in-progress");
|
||||
task.branch = "fusion/fn-001-custom";
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return " fusion/fn-001\n fusion/fn-001-custom\n fusion/fn-002\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([task]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
// Both fusion/fn-001 (derived) and fusion/fn-001-custom (stored) are active
|
||||
expect(orphaned).toEqual(["fusion/fn-002"]);
|
||||
});
|
||||
|
||||
it("returns empty array when git branch fails", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git branch")) {
|
||||
throw new Error("not a git repo");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
expect(orphaned).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when no fusion/* branches exist", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
expect(orphaned).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips leading * and whitespace from branch names", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("git branch")) {
|
||||
return "* fusion/fn-001\n fusion/fn-002\n";
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore([]);
|
||||
|
||||
const orphaned = await scanOrphanedBranches("/root", store);
|
||||
|
||||
expect(orphaned).toContain("fusion/fn-001");
|
||||
expect(orphaned).toContain("fusion/fn-002");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user