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,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);
});
});
});

View File

@@ -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,