feat(FN-3773): fix no-op re-squash in merge finalize instead of refusing me

Fixes the merger to re-squash instead of refusing when a no-op merge verification occurs mid-merge, with regression tests added to prevent recurrence. Also replaces hardcoded color/spacing values in ProjectCard and ProjectOverview CSS with design tokens for theme consistency.

Fusion-Task-Id: FN-3773
This commit is contained in:
Fusion
2026-05-08 21:23:07 -07:00
committed by gsxdsm
parent 1546eaf0d5
commit 111ad7afce
5 changed files with 190 additions and 52 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Tasks no longer strand in In Review when an in-merge verification fix only rebuilds gitignored artifacts. The merger now restores squash state and commits the original branch content when no commit exists yet, while still refusing real phantom merges with no task content.

View File

@@ -38,7 +38,7 @@
.project-card-errored {
border-color: var(--color-error);
box-shadow: 0 0 0 1px var(--color-error);
box-shadow: 0 0 0 calc(var(--space-xs) / 4) var(--color-error);
}
.project-card-header {
@@ -51,8 +51,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: calc(var(--space-xl) + var(--space-md));
height: calc(var(--space-xl) + var(--space-md));
background: var(--surface);
border-radius: var(--radius-md);
color: var(--todo);
@@ -64,11 +64,11 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--space-xs);
}
.project-card-name {
font-size: 15px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.75);
font-weight: 600;
color: var(--text);
margin: 0;
@@ -78,7 +78,7 @@
}
.project-card-path {
font-size: 12px;
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -91,10 +91,10 @@
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 11px;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: calc(var(--space-xs) / 8);
border: 1px solid currentColor;
flex-shrink: 0;
}
@@ -110,11 +110,11 @@
.project-card-metric {
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--space-xs);
}
.project-card-metric-value {
font-size: 18px;
font-size: calc(var(--space-lg) + var(--space-xs) * 0.5);
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
@@ -124,7 +124,7 @@
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: calc(var(--space-xs) / 8);
}
.project-card-metric-empty {
@@ -143,8 +143,8 @@
.project-card-activity {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
gap: calc(var(--space-sm) - var(--space-xs) * 0.5);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.project-card-activity-label {
@@ -165,12 +165,12 @@
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 6px 10px;
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-sm) + var(--space-xs) * 0.5);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 12px;
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 500;
cursor: pointer;
transition:
@@ -224,7 +224,7 @@
}
.project-card-action-remove {
padding: 6px;
padding: calc(var(--space-sm) - var(--space-xs) * 0.5);
color: var(--color-error);
}

View File

@@ -36,7 +36,7 @@
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 20px;
font-size: calc(var(--space-lg) + var(--space-xs));
font-weight: 700;
color: var(--text);
margin: 0;
@@ -148,13 +148,13 @@
.project-filter-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
gap: calc(var(--space-sm) - var(--space-xs) * 0.5);
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) var(--space-md);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 13px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
font-weight: 500;
cursor: pointer;
transition:
@@ -194,10 +194,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
min-width: calc(var(--space-lg) + var(--space-xs) * 0.5);
height: calc(var(--space-lg) + var(--space-xs) * 0.5);
padding: 0 var(--space-xs);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600;
font-family: var(--font-mono);
background: var(--border);
@@ -222,7 +222,7 @@
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
font-size: 13px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
}
.project-sort-select {
@@ -231,8 +231,8 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 13px;
padding: 6px 28px 6px 10px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-xl) + var(--space-xs)) calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-sm) + var(--space-xs) * 0.5);
cursor: pointer;
font-family: var(--font-primary);
transition:
@@ -258,7 +258,7 @@
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
font-size: 13px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
}
.project-node-filter-select {
@@ -267,8 +267,8 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 13px;
padding: 6px 28px 6px 10px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-xl) + var(--space-xs)) calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-sm) + var(--space-xs) * 0.5);
cursor: pointer;
font-family: var(--font-primary);
transition:
@@ -315,7 +315,7 @@
}
.project-overview__no-results p {
font-size: 15px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.75);
}
/* --- Empty State --- */
@@ -333,8 +333,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
width: calc(var(--space-2xl) * 2.5);
height: calc(var(--space-2xl) * 2.5);
background: var(--surface);
border: 2px dashed var(--border);
border-radius: var(--radius-xl);
@@ -342,17 +342,17 @@
}
.project-empty-state__title {
font-size: 20px;
font-size: calc(var(--space-lg) + var(--space-xs));
font-weight: 700;
color: var(--text);
margin: 0;
}
.project-empty-state__description {
font-size: 14px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
color: var(--text-muted);
line-height: 1.6;
max-width: 380px;
max-width: calc(var(--space-2xl) * 11.875);
}
.project-empty-state__cta {
@@ -360,8 +360,8 @@
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding: 10px 20px;
font-size: 14px;
padding: calc(var(--space-sm) + var(--space-xs) * 0.5) calc(var(--space-lg) + var(--space-xs));
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
font-weight: 600;
}
@@ -434,7 +434,7 @@
}
.project-skeleton--tab {
width: 80px;
width: calc(var(--space-2xl) * 2.5);
height: 32px;
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
@@ -559,7 +559,7 @@
}
.project-stat__value {
font-size: 14px;
font-size: calc(var(--space-md) + var(--space-xs) * 0.5);
}
.project-stat__label {

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { commitOrAmendMergeWithFixes } from "../merger.js";
import { DEFAULT_SETTINGS } from "@fusion/core";
function git(dir: string, cmd: string): string {
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().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"');
git(dir, "git config commit.gpgsign false");
writeFileSync(join(dir, "README.md"), "# repo\n");
git(dir, "git add README.md");
git(dir, 'git commit -m "chore: initial"');
}
function stageSquashThenClear(dir: string, branch: string, file: string, content: string): string {
git(dir, `git checkout -b ${branch}`);
writeFileSync(join(dir, file), content);
git(dir, `git add ${file}`);
git(dir, `git commit -m "feat: add ${file}"`);
git(dir, "git checkout main");
const preAttemptSha = git(dir, "git rev-parse HEAD");
git(dir, `git merge --squash ${branch}`);
// Simulate a no-op in-merge fix path where staged squash content gets cleared.
git(dir, "git reset HEAD -- .");
return preAttemptSha;
}
const STUB_SETTINGS = {
...DEFAULT_SETTINGS,
commitAuthorEnabled: false,
};
describe("commitOrAmendMergeWithFixes no-op finalize", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-noop-finalize-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("re-squashes and commits when fix run is a no-op but branch content exists", async () => {
const preAttemptSha = stageSquashThenClear(dir, "feat/noop", "feature-a.ts", "export const a = 1;\n");
expect(git(dir, "git diff --cached --name-only")).toBe("");
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptSha);
expect(readFileSync(join(dir, "feature-a.ts"), "utf-8")).toBe("export const a = 1;\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-3773",
"feat/noop",
"- feat: add feature-a.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set<string>(),
);
expect(result).toBe(true);
const committedFiles = git(dir, "git diff --name-only HEAD~1 HEAD").split("\n").filter(Boolean);
expect(committedFiles).toContain("feature-a.ts");
});
it("still refuses real phantom finalize when nothing staged and HEAD belongs to another task", async () => {
// Real phantom case: no current-task squash state and HEAD belongs to another task.
writeFileSync(join(dir, "outside.txt"), "outside\n");
git(dir, "git add outside.txt");
git(dir, 'git commit -m "feat: unrelated commit\n\nFusion-Task-Id: FN-OTHER"');
const preAttemptSha = git(dir, "git rev-parse HEAD");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-3773",
"feat/phantom",
"- feat: add feature-b.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set<string>(),
);
expect(result).toBe(false);
});
});

View File

@@ -2680,24 +2680,52 @@ export async function commitOrAmendMergeWithFixes(
const headMoved = currentHead !== preAttemptHeadSha;
if (!hasStaged && !headMoved) {
// Defense-in-depth: if HEAD already carries this task's `Fusion-Task-Id`
// trailer, the merge commit landed on a prior code path (e.g. AI commit
// in an earlier attempt) and there's simply nothing left for the fix to
// fold in. Record success rather than tripping the phantom-merge guard
// and stranding the task in In Review when the work is already on main.
// FN-1858 guardrail: never claim merge success when we cannot prove this
// task produced commit content. This finalize path distinguishes three
// terminal states: (1) committed-by-AI (HEAD already has this task ID),
// (2) no-op fix where squash state was cleared and must be restored, and
// (3) real phantom where there is truly no task content to commit.
if (await headCarriesTaskIdTrailer(rootDir, taskId)) {
mergerLog.log(
`${taskId}: HEAD already carries Fusion-Task-Id trailer — treating in-merge fix finalize as no-op success`,
);
return true;
}
// Truly nothing happened — neither a commit nor staged changes. Refuse
// to fabricate a successful merge: the caller will report failure.
mergerLog.warn(
`${taskId}: refusing to record merge — no commit was created and no changes are staged. ` +
`This usually means the AI agent never ran git commit and the in-merge fix had nothing to add.`,
);
return false;
// No commit and no staged content can still be recoverable when the
// in-merge fix path cleared the previous squash index state. Rebuild the
// squash from branch -> preAttemptHeadSha and continue normally.
try {
await execAsync(`git reset --hard ${preAttemptHeadSha}`, {
cwd: rootDir,
encoding: "utf-8",
});
await execAsync("git clean -fd", {
cwd: rootDir,
encoding: "utf-8",
});
await execAsync(`git merge --squash ${branch}`, {
cwd: rootDir,
encoding: "utf-8",
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to restore squash state before finalize: ${msg}`);
}
const { stdout: restoredStagedOut } = await execAsync("git diff --cached --name-only", {
cwd: rootDir,
encoding: "utf-8",
});
if (restoredStagedOut.trim().length === 0) {
mergerLog.warn(
`${taskId}: refusing to record merge — no commit was created and no changes are staged. ` +
`This usually means the AI agent never ran git commit and the in-merge fix had nothing to add.`,
);
return false;
}
mergerLog.log(`${taskId}: restored squash state after no-op verification fix; proceeding to commit`);
}
// Build the message from the actual commit content rather than the