feat(FN-4068): add branch conflict detection and recovery for stale worktre
Implements branch conflict detection and recovery across the Fusion engine, CLI, and dashboard — surfacing git worktree conflicts when tasks conflict with unrelated branch state, and providing a recovery workflow to resolve them. The executor and worktree pool now integrate typed branch conflict che Fusion-Task-Id: FN-4068
This commit is contained in:
@@ -14,6 +14,19 @@ vi.mock("node:fs", () => ({
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify } = await import("node:util");
|
||||
const execFn = vi.fn((cmd: string, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
if (typeof callback === "function") callback(null, "", "");
|
||||
}) as any;
|
||||
execFn[promisify.custom] = (cmd: string, opts?: any) =>
|
||||
new Promise((resolve) => {
|
||||
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
|
||||
});
|
||||
return { exec: execFn };
|
||||
});
|
||||
|
||||
// Mock @fusion/core before importing the module under test
|
||||
vi.mock("@fusion/core", () => {
|
||||
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
||||
@@ -45,7 +58,7 @@ vi.mock("@fusion/core", () => {
|
||||
});
|
||||
|
||||
// Mock @fusion/engine
|
||||
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
|
||||
|
||||
// Mock @fusion/dashboard
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
@@ -83,7 +96,8 @@ vi.mock("../../project-context.js", () => ({
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore, CentralCore } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||
import { exec } from "node:child_process";
|
||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||
import {
|
||||
getCurrentRepo,
|
||||
isGhAuthenticated,
|
||||
@@ -93,7 +107,9 @@ import {
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
||||
import { resolveProject } from "../../project-context.js";
|
||||
import { aiMergeTask } from "@fusion/engine";
|
||||
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
|
||||
|
||||
const mockedExec = vi.mocked(exec);
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -2052,6 +2068,153 @@ describe("runTaskRetry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTaskBranchRecovery", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockGetTask: ReturnType<typeof vi.fn>;
|
||||
let mockUpdateTask: ReturnType<typeof vi.fn>;
|
||||
let mockLogEntry: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
});
|
||||
mockGetTask = vi.fn().mockResolvedValue(makeTask({
|
||||
id: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
worktree: "/tmp/fn-001",
|
||||
executionStartBranch: "main",
|
||||
status: "failed",
|
||||
column: "todo",
|
||||
}));
|
||||
mockUpdateTask = vi.fn().mockResolvedValue(undefined);
|
||||
mockLogEntry = vi.fn().mockResolvedValue(undefined);
|
||||
mockedExec.mockReset();
|
||||
vi.mocked(listBranchRecoveryCandidates).mockReset();
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getTask: mockGetTask,
|
||||
updateTask: mockUpdateTask,
|
||||
logEntry: mockLogEntry,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prints branch recovery candidates", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001",
|
||||
tipSha: "abc123def456",
|
||||
worktreePath: "/tmp/fn-001",
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
|
||||
isCanonical: true,
|
||||
},
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001");
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Branch recovery candidates for FN-001");
|
||||
expect(output).toContain("fusion/fn-001 (canonical)");
|
||||
expect(output).toContain("abc123def456");
|
||||
expect(output).toContain("Sibling patch");
|
||||
});
|
||||
|
||||
it("reclaims the selected branch for the next run", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001",
|
||||
tipSha: "abc123def456",
|
||||
worktreePath: "/tmp/fn-001",
|
||||
strandedCommits: [],
|
||||
isCanonical: true,
|
||||
},
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001", { reclaim: "fusion/fn-001-2" });
|
||||
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
branch: "fusion/fn-001-2",
|
||||
worktree: "/tmp/fn-001-2",
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
expect(mockLogEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Branch recovery: reclaimed fusion/fn-001-2",
|
||||
"bbb222ccc333 @ /tmp/fn-001-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses discard without explicit confirmation", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2" })).rejects.toThrow("process.exit:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error: Refusing to discard branch recovery state without --yes");
|
||||
expect(mockedExec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards the selected branch and worktree when confirmed", async () => {
|
||||
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
|
||||
{
|
||||
branchName: "fusion/fn-001-2",
|
||||
tipSha: "bbb222ccc333",
|
||||
worktreePath: "/tmp/fn-001-2",
|
||||
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
|
||||
isCanonical: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2", yes: true });
|
||||
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
"git worktree remove '/tmp/fn-001-2' --force",
|
||||
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
"git branch -D 'fusion/fn-001-2'",
|
||||
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
||||
expect(mockLogEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Branch recovery: discarded fusion/fn-001-2",
|
||||
"bbb222ccc333 @ /tmp/fn-001-2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Logs Tests ---
|
||||
|
||||
describe("runTaskLogs", () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
|
||||
import { aiMergeTask } from "@fusion/engine";
|
||||
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { resolveProject, type ProjectContext } from "../project-context.js";
|
||||
import { findNodeByNameOrId } from "./node.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
|
||||
@@ -161,6 +164,70 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
||||
return (await getCommandContext(projectName)).projectPath;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function getCanonicalTaskBranch(taskId: string): string {
|
||||
return `fusion/${taskId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function formatRecoveryCandidate(candidate: BranchRecoveryCandidate): string[] {
|
||||
const lines = [
|
||||
` • ${candidate.branchName}${candidate.isCanonical ? " (canonical)" : ""}`,
|
||||
` tip: ${candidate.tipSha}`,
|
||||
` worktree: ${candidate.worktreePath ?? "(not attached to a worktree)"}`,
|
||||
];
|
||||
if (candidate.strandedCommits.length === 0) {
|
||||
lines.push(" stranded commits: none");
|
||||
} else {
|
||||
lines.push(" stranded commits:");
|
||||
for (const commit of candidate.strandedCommits) {
|
||||
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function runGit(projectPath: string, command: string): Promise<string> {
|
||||
const { stdout } = await execAsync(command, { cwd: projectPath, encoding: "utf-8" });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function resolveBranchRecoveryCandidates(id: string, projectName?: string): Promise<{
|
||||
store: TaskStore;
|
||||
projectPath: string;
|
||||
task: Awaited<ReturnType<TaskStore["getTask"]>>;
|
||||
canonicalBranch: string;
|
||||
candidates: BranchRecoveryCandidate[];
|
||||
}> {
|
||||
const context = await getCommandContext(projectName);
|
||||
const task = await context.store.getTask(id);
|
||||
const canonicalBranch = getCanonicalTaskBranch(task.id);
|
||||
const candidates = await listBranchRecoveryCandidates({
|
||||
repoDir: context.projectPath,
|
||||
branchName: canonicalBranch,
|
||||
startPoint: task.executionStartBranch ?? undefined,
|
||||
});
|
||||
return {
|
||||
store: context.store,
|
||||
projectPath: context.projectPath,
|
||||
task,
|
||||
canonicalBranch,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRecoveryCandidateOrExit(id: string, branch: string, projectName?: string) {
|
||||
const resolved = await resolveBranchRecoveryCandidates(id, projectName);
|
||||
const candidate = resolved.candidates.find((entry) => entry.branchName === branch);
|
||||
if (!candidate) {
|
||||
console.error(`Error: Branch recovery candidate not found for ${id}: ${branch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { ...resolved, candidate };
|
||||
}
|
||||
|
||||
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
@@ -830,6 +897,95 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskBranchRecovery(
|
||||
id: string,
|
||||
options: { reclaim?: string; discard?: string; yes?: boolean } = {},
|
||||
projectName?: string,
|
||||
) {
|
||||
if (options.reclaim && options.discard) {
|
||||
console.error("Error: --reclaim and --discard are mutually exclusive");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.reclaim) {
|
||||
const { store, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.reclaim, projectName);
|
||||
await store.updateTask(task.id, {
|
||||
branch: candidate.branchName,
|
||||
worktree: candidate.worktreePath,
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
`Branch recovery: reclaimed ${candidate.branchName}`,
|
||||
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Reclaimed ${candidate.branchName} for ${task.id}`);
|
||||
console.log(` Tip: ${candidate.tipSha}`);
|
||||
console.log(` Worktree: ${candidate.worktreePath ?? "(none)"}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.discard) {
|
||||
if (!options.yes) {
|
||||
console.error("Error: Refusing to discard branch recovery state without --yes");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { store, projectPath, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.discard, projectName);
|
||||
if (candidate.worktreePath) {
|
||||
await runGit(projectPath, `git worktree remove ${quoteShellArg(candidate.worktreePath)} --force`);
|
||||
}
|
||||
await runGit(projectPath, `git branch -D ${quoteShellArg(candidate.branchName)}`);
|
||||
|
||||
const patch: Record<string, unknown> = { status: null, error: null };
|
||||
if (task.branch === candidate.branchName) {
|
||||
patch.branch = null;
|
||||
}
|
||||
if (task.worktree && task.worktree === candidate.worktreePath) {
|
||||
patch.worktree = null;
|
||||
}
|
||||
await store.updateTask(task.id, patch);
|
||||
await store.logEntry(
|
||||
task.id,
|
||||
`Branch recovery: discarded ${candidate.branchName}`,
|
||||
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Discarded ${candidate.branchName} for ${task.id}`);
|
||||
if (candidate.worktreePath) {
|
||||
console.log(` Removed worktree: ${candidate.worktreePath}`);
|
||||
}
|
||||
console.log(` Deleted branch tip: ${candidate.tipSha}`);
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, candidates, canonicalBranch } = await resolveBranchRecoveryCandidates(id, projectName);
|
||||
|
||||
console.log();
|
||||
console.log(` Branch recovery candidates for ${task.id}`);
|
||||
console.log(` Canonical branch: ${canonicalBranch}`);
|
||||
console.log(` Current task branch: ${task.branch ?? "(none)"}`);
|
||||
console.log(` Current task worktree: ${task.worktree ?? "(none)"}`);
|
||||
if (candidates.length === 0) {
|
||||
console.log(" No matching canonical or sibling branches were found.");
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const line of formatRecoveryCandidate(candidate)) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user