fix(engine): tighten merger scope-warning diff base when baseBranch is missing
Mirrors dashboard `resolveDiffBase` display-recovery: when `baseBranch` is absent, compute `merge-base(HEAD, main)` and prefer it over a stale `baseCommitSha` only if it strictly descends that SHA. Pre-merge rebase on legacy/imported tasks (no recorded baseBranch) was producing inflated "N files changed outside declared File Scope" warnings — FN-3898 surfaced 17 ghost files for a 3-file change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/scope-warning-recovers-merge-base.md
Normal file
5
.changeset/scope-warning-recovers-merge-base.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Tighten merger scope-warning diff base for legacy/imported tasks lacking `baseBranch`. `resolveTaskDiffBaseRef` now mirrors the dashboard's display-recovery path: when `baseBranch` is missing, it computes `merge-base(HEAD, main)` and prefers it over a stale `baseCommitSha` only when the merge-base strictly descends the recorded SHA. Previously these tasks compared against the original fork point, so a pre-merge rebase pulled every unrelated commit landed on main into the diff and produced bogus "N files changed outside declared File Scope" warnings (e.g., FN-3898 saw 17 ghost files for a 3-file change). The FN-2855 deleted-feature-branch path is preserved.
|
||||
@@ -4807,6 +4807,58 @@ describe("resolveTaskDiffBaseRef", () => {
|
||||
|
||||
expect(diffBase).toBeUndefined();
|
||||
});
|
||||
|
||||
// FN-3898 regression: legacy/imported tasks may have a stale baseCommitSha
|
||||
// and no baseBranch. After pre-merge rebase the recorded SHA is older than
|
||||
// the new merge-base, so `baseCommitSha..branch` includes every unrelated
|
||||
// commit landed on main since the fork — inflating scope warnings.
|
||||
it("tightens to merge-base(HEAD, main) when baseBranch is missing and baseCommitSha is an outdated ancestor", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
// No baseBranch → outer merge-base block is skipped.
|
||||
// Display recovery: merge-base(HEAD, main).
|
||||
if (cmdStr === 'git merge-base "HEAD" main') return "current-main-sha" as any;
|
||||
// baseCommitSha is still an ancestor of HEAD…
|
||||
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "HEAD"') return "" as any;
|
||||
// …and recoveredBase descends baseCommitSha (rebase fast-forwarded).
|
||||
if (cmdStr === 'git merge-base --is-ancestor "old-base-sha" "current-main-sha"') return "" as any;
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: undefined,
|
||||
baseCommitSha: "old-base-sha",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("current-main-sha");
|
||||
});
|
||||
|
||||
// Preserves the FN-2855 path: when the recovered merge-base is NOT a
|
||||
// descendant of baseCommitSha (e.g., baseCommitSha lives on a deleted
|
||||
// upstream feature branch), keep the task-scoped SHA rather than widening
|
||||
// the diff range.
|
||||
it("keeps baseCommitSha when recoveredBase does not descend it", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git merge-base "HEAD" main') return "unrelated-main-sha" as any;
|
||||
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "HEAD"') return "" as any;
|
||||
if (cmdStr === 'git merge-base --is-ancestor "feature-base-sha" "unrelated-main-sha"') {
|
||||
throw new Error("not an ancestor");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmdStr}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveTaskDiffBaseRef({
|
||||
cwd: "/tmp/root",
|
||||
headRef: "HEAD",
|
||||
baseBranch: undefined,
|
||||
baseCommitSha: "feature-base-sha",
|
||||
});
|
||||
|
||||
expect(diffBase).toBe("feature-base-sha");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aiMergeTask — post-merge workflow steps", () => {
|
||||
|
||||
@@ -3095,18 +3095,63 @@ export async function resolveTaskDiffBaseRef({
|
||||
}
|
||||
}
|
||||
|
||||
// Display recovery (mirrors dashboard `resolveDiffBase` with
|
||||
// `enableDisplayRecovery: true`): when baseBranch is missing — common for
|
||||
// legacy/imported tasks — compute merge-base(headRef, main) so we can
|
||||
// tighten an outdated-but-still-ancestor baseCommitSha after a pre-merge
|
||||
// rebase. Without this the scope warning compares against a stale
|
||||
// baseCommitSha and surfaces every unrelated commit landed on main since
|
||||
// the task forked.
|
||||
let recoveredBase: string | undefined;
|
||||
if (!baseBranch?.trim()) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} main`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
recoveredBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg("origin/main")}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
recoveredBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
// no recovery available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (baseCommitSha) {
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${quoteArg(baseCommitSha)} ${quotedHeadRef}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
// Prefer recoveredBase only if it's strictly tighter (a descendant of
|
||||
// baseCommitSha). When baseCommitSha lives on a deleted feature branch
|
||||
// it won't be an ancestor of merge-base(HEAD, main), so we keep the
|
||||
// task-scoped SHA — preserves the FN-2855 nulled-baseBranch path.
|
||||
if (recoveredBase && recoveredBase !== baseCommitSha) {
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${quoteArg(baseCommitSha)} ${quoteArg(recoveredBase)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return recoveredBase;
|
||||
} catch {
|
||||
// recoveredBase not a descendant — keep baseCommitSha
|
||||
}
|
||||
}
|
||||
return baseCommitSha;
|
||||
} catch {
|
||||
// stale or unreachable — fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredBase) return recoveredBase;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse ${quoteArg(`${headRef}~1`)}`, {
|
||||
cwd,
|
||||
|
||||
Reference in New Issue
Block a user