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:
5
.changeset/FN-3756-autostash-cleanup.md
Normal file
5
.changeset/FN-3756-autostash-cleanup.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
125
packages/engine/src/__tests__/merger-autostash-cleanup.test.ts
Normal file
125
packages/engine/src/__tests__/merger-autostash-cleanup.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user