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:
@@ -1,4 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Mock child_process so we can intercept execSync calls in branch cleanup tests.
|
||||
// By default, pass through to the real implementation.
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...mod,
|
||||
execSync: vi.fn((...args: Parameters<typeof mod.execSync>) => mod.execSync(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
|
||||
import { TaskStore } from "./store.js";
|
||||
import { readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -5312,4 +5326,117 @@ Task with acceptance criteria
|
||||
expect(detail.nextRecoveryAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Branch Cleanup on Delete/Archive ────────────────────────────
|
||||
|
||||
describe("branch cleanup on delete and archive", () => {
|
||||
beforeEach(() => {
|
||||
mockedExecSync.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedExecSync.mockImplementation(
|
||||
(...args: Parameters<typeof execSync>) => {
|
||||
// Restore pass-through to real implementation
|
||||
const { execSync: realExecSync } = require("node:child_process");
|
||||
return realExecSync(...args);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteTask attempts branch cleanup via cleanupBranchForTask", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
// Mock: verify succeeds, delete succeeds
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deleteTask cleans up stored branch and derived branch when set", async () => {
|
||||
const task = await store.createTask({ description: "Branch test" });
|
||||
await store.updateTask(task.id, { branch: "fusion/my-custom-branch" });
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
// Should verify and delete both stored and derived branches
|
||||
const customBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/my-custom-branch"`));
|
||||
const customBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/my-custom-branch"`));
|
||||
const derivedBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/${task.id.toLowerCase()}"`));
|
||||
const derivedBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/${task.id.toLowerCase()}"`));
|
||||
expect(customBranchVerify.length).toBeGreaterThanOrEqual(1);
|
||||
expect(customBranchDelete.length).toBeGreaterThanOrEqual(1);
|
||||
expect(derivedBranchVerify.length).toBeGreaterThanOrEqual(1);
|
||||
expect(derivedBranchDelete.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deleteTask succeeds even when branch cleanup fails", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("not a git repo");
|
||||
});
|
||||
|
||||
const deleted = await store.deleteTask(task.id);
|
||||
expect(deleted.id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("archiveTask with cleanup attempts branch cleanup", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (typeof cmd === "string" && cmd.includes("git rev-parse --verify")) return Buffer.from("");
|
||||
if (typeof cmd === "string" && cmd.includes("git branch -D")) return Buffer.from("");
|
||||
throw new Error(`unexpected execSync call: ${cmd}`);
|
||||
});
|
||||
|
||||
await store.archiveTask(task.id, true);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("archiveTask without cleanup does NOT attempt branch cleanup", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("mocked: no git repo");
|
||||
});
|
||||
|
||||
await store.archiveTask(task.id, false);
|
||||
|
||||
const calls = mockedExecSync.mock.calls.map((c) => c[0] as string);
|
||||
const branchCommands = calls.filter((c) => c.includes("git branch -D") || c.includes("git rev-parse --verify"));
|
||||
expect(branchCommands).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1395,6 +1395,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
|
||||
// Clean up the task's branch before deleting from DB
|
||||
const cleanedBranches = this.cleanupBranchForTask(task);
|
||||
if (cleanedBranches.length > 0) {
|
||||
if (!task.log) task.log = [];
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: `Cleaned up branch: ${cleanedBranches.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from SQLite
|
||||
this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
||||
this.db.bumpLastModified();
|
||||
@@ -1414,6 +1424,47 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the git branch associated with a task.
|
||||
*
|
||||
* Branch name resolution:
|
||||
* 1. Use `task.branch` if set
|
||||
* 2. Fall back to `fusion/${taskId.toLowerCase()}`
|
||||
*
|
||||
* Uses force delete (`git branch -D`) since the task is being removed or archived.
|
||||
* Silently skips if neither branch exists (idempotent).
|
||||
*
|
||||
* @returns Array of branch names that were successfully deleted
|
||||
*/
|
||||
private cleanupBranchForTask(task: Task): string[] {
|
||||
const branches = new Set<string>();
|
||||
if (task.branch) {
|
||||
branches.add(task.branch);
|
||||
}
|
||||
branches.add(`fusion/${task.id.toLowerCase()}`);
|
||||
|
||||
const deleted: string[] = [];
|
||||
for (const branch of branches) {
|
||||
try {
|
||||
// Verify branch exists before trying to delete
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
execSync(`git branch -D "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
timeout: 10_000,
|
||||
});
|
||||
deleted.push(branch);
|
||||
} catch {
|
||||
// Branch doesn't exist or deletion failed — silently skip
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): import("./types.js").MergeDetails {
|
||||
const mergedAt = new Date().toISOString();
|
||||
let commitSha: string | undefined;
|
||||
@@ -1635,6 +1686,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
// If cleanup requested, write archive entry BEFORE removing directory
|
||||
if (cleanup) {
|
||||
// Clean up the task's branch before removing from DB
|
||||
const cleanedBranches = this.cleanupBranchForTask(task);
|
||||
if (cleanedBranches.length > 0) {
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: `Cleaned up branch: ${cleanedBranches.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const entry: import("./types.js").ArchivedTaskEntry = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user