feat(FN-3756): extract roadmap UI into bundled plugin and add autostash orp

Merges major roadmap plugin extraction (FN-3160/3161/3162), hardening the merger with autostash orphan cleanup and TOCTOU defenses (FN-3755/3756), adding shared state snapshots for mesh sync (FN-3451), shipping polling task notifications for the even realities plugin (FN-3743), defaulting non-epheme

Fusion-Task-Id: FN-3756
This commit is contained in:
Fusion
2026-05-08 17:39:50 -07:00
committed by gsxdsm
parent a3299f72b0
commit d63398148d
4 changed files with 189 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix merger autostash orphan cleanup to automatically drop closed-task stashes whose content is already fully subsumed by HEAD.

View File

@@ -1198,6 +1198,7 @@ Git dashboard routes are registered in `register-git-github.ts`.
### Merge strategies ### Merge strategies
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`) - Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
- `aiMergeTask()` in `merger.ts` performs merge flow - `aiMergeTask()` in `merger.ts` performs merge flow
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
- Supports workflow-step execution after merge (post-merge phase) - Supports workflow-step execution after merge (post-merge phase)
### Conflict handling ### Conflict handling

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import type { TaskStore } from "@fusion/core";
import { __test__ } from "../merger.js";
const { sweepAutostashOrphans, parseAutostashTaskId } = __test__;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
}
function initRepo(dir: string): void {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
writeFileSync(join(dir, "file.txt"), "base\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "init"');
}
function createAutostash(dir: string, taskId: string, content: string): string {
const label = `fusion-merger-autostash:${taskId}:${Date.now()}`;
writeFileSync(join(dir, "file.txt"), content);
git(dir, "git add file.txt");
const sha = git(dir, "git stash create");
git(dir, `git stash store -m ${JSON.stringify(label)} ${sha}`);
git(dir, "git reset --hard HEAD");
const list = stashList(dir);
if (!list.includes(label)) {
// Older git versions may ignore `stash store -m` for create/store objects.
git(dir, "git stash drop stash@{0}");
writeFileSync(join(dir, "file.txt"), content);
git(dir, `git stash push -m ${JSON.stringify(label)} file.txt`);
return git(dir, 'git stash list --format="%H" -n 1');
}
return sha;
}
function stashList(dir: string): string {
return git(dir, 'git stash list --format="%H %gd %s"');
}
function makeStore(tasks: Record<string, string>, opts?: { throwOnGetTask?: boolean }): TaskStore {
return {
getTask: async (taskId: string) => {
if (opts?.throwOnGetTask) throw new Error("boom");
const column = tasks[taskId];
if (!column) return null;
return { id: taskId, column } as any;
},
logEntry: async () => undefined,
} as unknown as TaskStore;
}
describe("parseAutostashTaskId", () => {
it("parses valid labels and rejects malformed/foreign labels", () => {
expect(parseAutostashTaskId("fusion-merger-autostash:FN-3485:123")).toBe("FN-3485");
expect(parseAutostashTaskId("fusion-merger-autostash:FN-3485")).toBeNull();
expect(parseAutostashTaskId("fusion-merger-autostash::123")).toBeNull();
expect(parseAutostashTaskId("WIP on main: abcdef")).toBeNull();
});
});
describe("sweepAutostashOrphans", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-autostash-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("drops closed-task done orphan when net diff is empty", async () => {
createAutostash(dir, "FN-1001", "done-content\n");
writeFileSync(join(dir, "file.txt"), "done-content\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "match stash"');
await sweepAutostashOrphans(dir, "FN-MERGE", makeStore({ "FN-1001": "done" }));
expect(stashList(dir)).toBe("");
});
it("preserves closed-task archived orphan when stash still differs", async () => {
const sha = createAutostash(dir, "FN-1002", "archived-content\n");
await sweepAutostashOrphans(dir, "FN-MERGE", makeStore({ "FN-1002": "archived" }));
expect(stashList(dir)).toContain(sha);
});
it("drops open-task orphan with empty path diff via existing subsumed path", async () => {
createAutostash(dir, "FN-1003", "open-matched\n");
writeFileSync(join(dir, "file.txt"), "open-matched\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "match open stash"');
await sweepAutostashOrphans(dir, "FN-MERGE", makeStore({ "FN-1003": "in-progress" }));
expect(stashList(dir)).toBe("");
});
it("preserves open-task orphan with real diff", async () => {
const sha = createAutostash(dir, "FN-1004", "open-live\n");
await sweepAutostashOrphans(dir, "FN-MERGE", makeStore({ "FN-1004": "in-progress" }));
expect(stashList(dir)).toContain(sha);
});
it("preserves orphan when store.getTask throws", async () => {
const sha = createAutostash(dir, "FN-1005", "throw-case\n");
await sweepAutostashOrphans(dir, "FN-MERGE", makeStore({}, { throwOnGetTask: true }));
expect(stashList(dir)).toContain(sha);
});
});

View File

@@ -1258,6 +1258,11 @@ async function listOrphanedAutostashes(
} }
} }
function parseAutostashTaskId(label: string): string | null {
const match = /^fusion-merger-autostash:([A-Za-z]+-\d+):/.exec(label.trim());
return match?.[1] ?? null;
}
/** /**
* Stash any unrelated dirty changes in `rootDir` before a merge runs. * Stash any unrelated dirty changes in `rootDir` before a merge runs.
* *
@@ -1326,6 +1331,8 @@ async function sweepAutostashOrphans(
const subsumed: Array<{ sha: string; ref: string; label: string }> = []; const subsumed: Array<{ sha: string; ref: string; label: string }> = [];
const live: Array<{ sha: string; ref: string; label: string }> = []; const live: Array<{ sha: string; ref: string; label: string }> = [];
const droppedClosedTask: Array<{ sha: string; taskId: string; column: Task["column"] }> = [];
for (const orphan of orphans) { for (const orphan of orphans) {
try { try {
const stashFiles = await listStashChangedPaths(rootDir, orphan.sha); const stashFiles = await listStashChangedPaths(rootDir, orphan.sha);
@@ -1335,15 +1342,50 @@ async function sweepAutostashOrphans(
continue; continue;
} }
const pathsArg = [...stashFiles].map(quoteArg).join(" "); const pathsArg = [...stashFiles].map(quoteArg).join(" ");
const { stdout } = await execAsync( const { stdout: pathDiffOut } = await execAsync(
`git diff --name-only HEAD ${quoteArg(orphan.sha)} -- ${pathsArg}`, `git diff --name-only HEAD ${quoteArg(orphan.sha)} -- ${pathsArg}`,
{ cwd: rootDir, encoding: "utf-8" }, { cwd: rootDir, encoding: "utf-8" },
); );
if (stdout.trim() === "") { const isPathSubsumed = pathDiffOut.trim() === "";
if (isPathSubsumed) {
subsumed.push(orphan); subsumed.push(orphan);
} else { continue;
live.push(orphan);
} }
const sourceTaskId = parseAutostashTaskId(orphan.label);
if (!sourceTaskId) {
live.push(orphan);
continue;
}
let sourceTask: Task | null = null;
try {
sourceTask = await store.getTask(sourceTaskId);
} catch {
live.push(orphan);
continue;
}
if (!sourceTask || (sourceTask.column !== "done" && sourceTask.column !== "archived")) {
live.push(orphan);
continue;
}
try {
const { stdout: netDiffOut } = await execAsync(
`git diff HEAD ${quoteArg(orphan.sha)}`,
{ cwd: rootDir, encoding: "utf-8" },
);
if (netDiffOut.trim() === "") {
subsumed.push(orphan);
droppedClosedTask.push({ sha: orphan.sha, taskId: sourceTaskId, column: sourceTask.column });
continue;
}
} catch {
live.push(orphan);
continue;
}
live.push(orphan);
} catch { } catch {
// If we can't classify, treat as live — better to leave a real stash // If we can't classify, treat as live — better to leave a real stash
// sitting around than to drop one that still contains lost work. // sitting around than to drop one that still contains lost work.
@@ -1353,6 +1395,13 @@ async function sweepAutostashOrphans(
for (const orphan of subsumed) { for (const orphan of subsumed) {
await dropAutostashBySha(rootDir, taskId, orphan.sha); await dropAutostashBySha(rootDir, taskId, orphan.sha);
const closedTaskDrop = droppedClosedTask.find((entry) => entry.sha === orphan.sha);
if (closedTaskDrop) {
mergerLog.log(
`${taskId}: dropped closed-task autostash ${orphan.sha.slice(0, 7)} (task ${closedTaskDrop.taskId} is ${closedTaskDrop.column})`,
);
continue;
}
mergerLog.log( mergerLog.log(
`${taskId}: dropped subsumed autostash ${orphan.sha.slice(0, 7)} (${orphan.label}) — content already present on HEAD`, `${taskId}: dropped subsumed autostash ${orphan.sha.slice(0, 7)} (${orphan.label}) — content already present on HEAD`,
); );
@@ -1388,6 +1437,11 @@ async function sweepAutostashOrphans(
} }
} }
export const __test__ = {
sweepAutostashOrphans,
parseAutostashTaskId,
};
async function stashUnrelatedRootDirChanges( async function stashUnrelatedRootDirChanges(
rootDir: string, rootDir: string,
taskId: string, taskId: string,