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:
gsxdsm
2026-04-04 18:31:18 -07:00
parent bc980926e0
commit e105da2a6e
6 changed files with 542 additions and 3 deletions

View File

@@ -1,7 +1,24 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
vi.mock("./worktree-pool.js", () => ({
WorktreePool: vi.fn(),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
}));
import { SelfHealingManager } from "./self-healing.js";
import type { TaskStore, Settings, Task } from "@fusion/core";
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { scanOrphanedBranches } from "./worktree-pool.js";
const mockedExecSync = vi.mocked(execSync);
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
// ── Mock helpers ────────────────────────────────────────────────────
@@ -291,4 +308,82 @@ describe("SelfHealingManager", () => {
expect(store.updateSettings).not.toHaveBeenCalled();
});
});
// ── cleanupOrphanedBranches ────────────────────────────────────────
describe("cleanupOrphanedBranches", () => {
it("returns 0 when no orphaned branches found", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("deletes orphaned branches with safe delete (-d)", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-001", "fusion/fn-002"]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(2);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git branch -d "fusion/fn-001"'),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git branch -d "fusion/fn-002"'),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
});
it("falls back to force delete (-D) when safe delete fails", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-003"]);
// Safe delete fails
mockedExecSync.mockImplementationOnce(() => {
throw new Error("not fully merged");
});
// Force delete succeeds
mockedExecSync.mockImplementationOnce(() => Buffer.from(""));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git branch -d "fusion/fn-003"'),
expect.any(Object),
);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining('git branch -D "fusion/fn-003"'),
expect.any(Object),
);
});
it("counts only successfully deleted branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-004", "fusion/fn-005"]);
// First branch: safe delete succeeds
mockedExecSync.mockImplementationOnce(() => Buffer.from(""));
// Second branch: both safe and force delete fail
mockedExecSync.mockImplementationOnce(() => {
throw new Error("not fully merged");
});
mockedExecSync.mockImplementationOnce(() => {
throw new Error("branch not found");
});
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
});
it("returns 0 when scanOrphanedBranches throws", async () => {
mockedScanOrphanedBranches.mockRejectedValueOnce(new Error("git error"));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
});
});
});

View File

@@ -18,7 +18,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { TaskStore, Settings } from "@fusion/core";
import { createLogger } from "./logger.js";
import { scanIdleWorktrees } from "./worktree-pool.js";
import { scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
const log = createLogger("self-healing");
@@ -232,6 +232,7 @@ export class SelfHealingManager {
try {
await this.pruneWorktrees();
await this.cleanupOrphans();
await this.cleanupOrphanedBranches();
this.checkpointWal();
await this.enforceWorktreeCap();
@@ -292,6 +293,61 @@ export class SelfHealingManager {
}
}
/**
* Remove orphaned `fusion/*` branches that are not associated with any
* active (non-archived, non-merger-managed) task.
*
* For each orphaned branch:
* 1. Try `git branch -d` (safe delete — only works if branch is fully merged)
* 2. Fall back to `git branch -D` (force delete) if safe delete fails
* 3. Log each cleanup action
*
* Individual branch deletion failures are non-fatal.
*
* @returns Number of branches successfully deleted
*/
async cleanupOrphanedBranches(): Promise<number> {
try {
const orphaned = await scanOrphanedBranches(this.options.rootDir, this.store);
if (orphaned.length === 0) return 0;
let cleaned = 0;
for (const branch of orphaned) {
try {
// Try safe delete first (-d requires branch to be merged)
execSync(`git branch -d "${branch}"`, {
cwd: this.options.rootDir,
stdio: "pipe",
timeout: 30_000,
});
log.log(`Deleted branch: ${branch}`);
cleaned++;
} catch {
// Safe delete failed (not merged) — force delete
try {
execSync(`git branch -D "${branch}"`, {
cwd: this.options.rootDir,
stdio: "pipe",
timeout: 30_000,
});
log.log(`Force-deleted branch: ${branch}`);
cleaned++;
} catch {
// Individual failure is non-fatal
}
}
}
if (cleaned > 0) {
log.log(`Cleaned ${cleaned} orphaned branch(es)`);
}
return cleaned;
} catch (err: any) {
log.error(`Orphaned branch cleanup failed: ${err.message}`);
return 0;
}
}
/** Run SQLite WAL checkpoint to reclaim disk space. */
private checkpointWal(): void {
try {

View File

@@ -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");
});
});

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import type { TaskStore } from "@fusion/core";
import type { Column, TaskStore } from "@fusion/core";
import { worktreePoolLog } from "./logger.js";
/**
@@ -265,3 +265,60 @@ export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore
return cleaned;
}
/** Columns where the merger handles branch cleanup — skip these during orphan scanning. */
const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"]);
/**
* Scan for orphaned `fusion/*` branches that are not associated with any
* non-archived, non-merger-managed task.
*
* Lists all local branches matching the `fusion/*` pattern, then compares
* against branches stored on tasks (via `task.branch` or derived as
* `fusion/${taskId.toLowerCase()}`). Branches belonging to tasks in the
* `in-review` or `done` columns are excluded because the merger is
* responsible for cleaning those up.
*
* @param rootDir — Project root directory (git working tree)
* @param store — Task store for listing tasks and their branch assignments
* @returns Array of orphaned branch names
*/
export async function scanOrphanedBranches(rootDir: string, store: TaskStore): Promise<string[]> {
// List all local branches matching fusion/*
let allBranches: string[];
try {
const output = execSync("git branch --list 'fusion/*'", {
cwd: rootDir,
stdio: "pipe",
encoding: "utf-8",
});
allBranches = output
.split("\n")
.map((line) => line.trim().replace(/^\*?\s*/, ""))
.filter((line) => line.startsWith("fusion/"));
} catch {
return [];
}
if (allBranches.length === 0) return [];
// Build set of branches associated with active (non-archived, non-merger-managed) tasks
const tasks = await store.listTasks();
const activeBranches = new Set<string>();
for (const task of tasks) {
// Skip tasks in columns where the merger handles branch cleanup
if (MERGER_MANAGED_COLUMNS.has(task.column)) continue;
// Also skip archived tasks
if (task.column === "archived") continue;
// Use stored branch name if available, otherwise derive from task ID
if (task.branch) {
activeBranches.add(task.branch);
}
// Always add the derived name too — the task may not have `branch` set yet
activeBranches.add(`fusion/${task.id.toLowerCase()}`);
}
// Return branches not associated with any active task
return allBranches.filter((branch) => !activeBranches.has(branch));
}