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:
105
packages/engine/src/__tests__/merger-no-op-fix-finalize.test.ts
Normal file
105
packages/engine/src/__tests__/merger-no-op-fix-finalize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user