The FN-5627 default-threshold fix changed the prerebase threshold default from 0 (never fire on commit-count) to 1 (fire on any divergence). But that only affected projects WITHOUT an explicit threshold. Projects with user-set values like 'prerebaseDivergenceThreshold: 50' continued to skip prerebase for small divergences (e.g., 4 commits behind), so the squash built against stale base and update-ref refused non-FF \u2014 producing the same-SHA spurious-concurrent-advance signature that stranded FN-5626/FN-5628/FN-5633. Root distinction missed earlier: - prerebaseDivergenceThreshold is for USER-VISIBLE SEVERITY REPORTING (this branch is N commits behind, warn me). - Engine correctness requires a SAFETY INVARIANT (any branch behind main MUST be rebased before squash or update-ref will fail). These are independent concerns. The safety invariant must not be gated on the user's threshold. decideAutoPrerebase() now returns fire=true with reason 'safety-fallback-any-divergence' whenever commitsBehind > 0, after the hot-file and threshold checks. The threshold path still wins the reason label when its condition trips, so user-visible severity reporting is unchanged for non-pathological cases. Full opt-out remains prerebaseAutoEnabled=false (skips the safety fallback; user accepts behind-branch merges will fail). prerebaseDivergenceThreshold=0 is no longer a complete opt-out from the commit-count gate \u2014 it only suppresses the threshold-based reason label. Tests (4 updated/new): - safety-fallback-any-divergence reason added to AutoPrerebaseDecision - 4 commits behind with threshold=50 fires via safety fallback - prerebaseAutoEnabled=false respects full opt-out - threshold trip still wins reason label - commitsBehind=0 returns no-divergence (unchanged) Engine suite: 6163 tests pass. In-flight: FN-5626, FN-5628, FN-5633 manually SQL-reset to mergeRetries=0, status=null, error=null, transientRecoveryCount=0 so the next merger tick (after engine restart picks up this code) auto-prerebases via safety fallback and lands the work. Future occurrences self-heal automatically. Fusion-Task-Id: FN-5627
243 lines
9.7 KiB
TypeScript
243 lines
9.7 KiB
TypeScript
import { execSync, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "../merger-auto-prerebase.js";
|
|
|
|
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
|
const describeIfGit = hasGit ? describe : describe.skip;
|
|
|
|
function git(repo: string, command: string): string {
|
|
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
}
|
|
|
|
describe("decideAutoPrerebase", () => {
|
|
const settings = {
|
|
prerebaseAutoEnabled: true,
|
|
prerebaseHotFiles: ["AGENTS.md"],
|
|
prerebaseDivergenceThreshold: 50,
|
|
};
|
|
|
|
it("short-circuits for worktrunk first", () => {
|
|
expect(decideAutoPrerebase({ settings, baseCommitSha: "abc", commitsBehind: 99, changedFiles: ["AGENTS.md"], worktrunkEnabled: true }).reason).toBe("worktrunk-deferred");
|
|
});
|
|
|
|
it("returns disabled when prerebaseAutoEnabled is false", () => {
|
|
expect(decideAutoPrerebase({ settings: { ...settings, prerebaseAutoEnabled: false }, baseCommitSha: "abc", commitsBehind: 99, changedFiles: ["AGENTS.md"], worktrunkEnabled: false }).reason).toBe("disabled");
|
|
});
|
|
|
|
it("returns no-base when base commit is missing", () => {
|
|
expect(decideAutoPrerebase({ settings, baseCommitSha: undefined, commitsBehind: 99, changedFiles: ["AGENTS.md"], worktrunkEnabled: false }).reason).toBe("no-base");
|
|
});
|
|
|
|
it("prefers hot-file trigger over threshold", () => {
|
|
const decision = decideAutoPrerebase({ settings, baseCommitSha: "abc", commitsBehind: 99, changedFiles: ["AGENTS.md", "other.ts"], worktrunkEnabled: false });
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("hot-file");
|
|
expect(decision.hotMatches).toEqual(["AGENTS.md"]);
|
|
});
|
|
|
|
it("fires on divergence threshold when configured", () => {
|
|
const decision = decideAutoPrerebase({ settings, baseCommitSha: "abc", commitsBehind: 51, changedFiles: ["x.ts"], worktrunkEnabled: false });
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("divergence-threshold");
|
|
});
|
|
|
|
it("returns no-divergence only when branch is fully up-to-date", () => {
|
|
// FN-5627 update: was 'returns no-divergence when nothing triggers'.
|
|
// Now safety-fallback fires for any commitsBehind > 0, so a true
|
|
// no-divergence requires commitsBehind === 0.
|
|
const decision = decideAutoPrerebase({ settings, baseCommitSha: "abc", commitsBehind: 0, changedFiles: [], worktrunkEnabled: false });
|
|
expect(decision.fire).toBe(false);
|
|
expect(decision.reason).toBe("no-divergence");
|
|
});
|
|
|
|
it("FN-5627: safety fallback fires for any commitsBehind > 0 even when threshold not tripped", () => {
|
|
// Root cause of FN-5626/FN-5633 strandings: user's project config had
|
|
// `prerebaseDivergenceThreshold: 50` for low-noise PR experience.
|
|
// FN-5626 was only 4 commits behind main, so the threshold path didn't
|
|
// fire, prerebase skipped, squash built against stale base, update-ref
|
|
// refused non-FF — task stranded. The safety fallback ensures any branch
|
|
// behind main rebases before squash, regardless of threshold.
|
|
const decision = decideAutoPrerebase({
|
|
settings, // threshold=50
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 4,
|
|
changedFiles: ["x.ts"],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("safety-fallback-any-divergence");
|
|
expect(decision.commitsBehind).toBe(4);
|
|
});
|
|
|
|
it("FN-5627: safety fallback respects prerebaseAutoEnabled=false opt-out", () => {
|
|
const decision = decideAutoPrerebase({
|
|
settings: { ...settings, prerebaseAutoEnabled: false },
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 4,
|
|
changedFiles: ["x.ts"],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(false);
|
|
expect(decision.reason).toBe("disabled");
|
|
});
|
|
|
|
it("FN-5627: divergence-threshold still wins over safety-fallback when configured threshold is tripped", () => {
|
|
const decision = decideAutoPrerebase({
|
|
settings, // threshold=50
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 51,
|
|
changedFiles: ["x.ts"],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("divergence-threshold");
|
|
});
|
|
|
|
it("FN-5627: fires on default threshold (1 commit) when prerebaseDivergenceThreshold is undefined", () => {
|
|
// Pre-FN-5627 default behavior: undefined threshold meant 'never fire on
|
|
// commit-count'. This left tasks that branched off older main tips with
|
|
// no recourse — the merger would build the squash against the stale
|
|
// base, update-ref would refuse non-FF, and IntegrationBranchConcurrentAdvanceError
|
|
// surfaced with a misleading same-SHA pair (the exact signature that
|
|
// stranded FN-5632). New default: threshold=1, fire on any divergence.
|
|
const settingsNoThreshold = {
|
|
prerebaseAutoEnabled: true,
|
|
prerebaseHotFiles: ["AGENTS.md"],
|
|
// prerebaseDivergenceThreshold intentionally undefined
|
|
};
|
|
const decision = decideAutoPrerebase({
|
|
settings: settingsNoThreshold,
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 1,
|
|
changedFiles: ["x.ts"],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("divergence-threshold");
|
|
expect(decision.commitsBehind).toBe(1);
|
|
});
|
|
|
|
it("FN-5627: explicit prerebaseDivergenceThreshold = 0 still fires via safety-fallback (full opt-out requires prerebaseAutoEnabled=false)", () => {
|
|
// FN-5627 semantics: `prerebaseDivergenceThreshold = 0` only opts out of
|
|
// the threshold-based trigger; the safety fallback still fires when the
|
|
// branch is behind, because the alternative is a guaranteed update-ref
|
|
// failure. Full opt-out requires `prerebaseAutoEnabled = false`.
|
|
const settingsThresholdZero = {
|
|
prerebaseAutoEnabled: true,
|
|
prerebaseHotFiles: ["AGENTS.md"],
|
|
prerebaseDivergenceThreshold: 0,
|
|
};
|
|
const decision = decideAutoPrerebase({
|
|
settings: settingsThresholdZero,
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 100,
|
|
changedFiles: ["x.ts"],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(true);
|
|
expect(decision.reason).toBe("safety-fallback-any-divergence");
|
|
});
|
|
|
|
it("FN-5627: default threshold doesn't fire when branch is up-to-date", () => {
|
|
const settingsNoThreshold = {
|
|
prerebaseAutoEnabled: true,
|
|
prerebaseHotFiles: ["AGENTS.md"],
|
|
};
|
|
const decision = decideAutoPrerebase({
|
|
settings: settingsNoThreshold,
|
|
baseCommitSha: "abc",
|
|
commitsBehind: 0,
|
|
changedFiles: [],
|
|
worktrunkEnabled: false,
|
|
});
|
|
expect(decision.fire).toBe(false);
|
|
expect(decision.reason).toBe("no-divergence");
|
|
});
|
|
});
|
|
|
|
describeIfGit("merger-auto-prerebase git integration", () => {
|
|
const dirs: string[] = [];
|
|
afterEach(() => {
|
|
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function setupRepo() {
|
|
const repo = mkdtempSync(join(tmpdir(), "fusion-prerebase-"));
|
|
dirs.push(repo);
|
|
git(repo, "git init -b main");
|
|
git(repo, 'git config user.email "test@example.com"');
|
|
git(repo, 'git config user.name "Test User"');
|
|
writeFileSync(join(repo, "a.txt"), "a\n");
|
|
writeFileSync(join(repo, "shared.txt"), "base\n");
|
|
git(repo, "git add a.txt shared.txt && git commit -m 'A'");
|
|
const a = git(repo, "git rev-parse HEAD");
|
|
writeFileSync(join(repo, "b.txt"), "b\n");
|
|
git(repo, "git add b.txt && git commit -m 'B'");
|
|
writeFileSync(join(repo, "c.txt"), "c\n");
|
|
git(repo, "git add c.txt && git commit -m 'C'");
|
|
return { repo, a };
|
|
}
|
|
|
|
it("probes divergence count and files", async () => {
|
|
const { repo, a } = setupRepo();
|
|
const result = await probeDivergence({ rootDir: repo, baseCommitSha: a });
|
|
expect(result.commitsBehind).toBe(2);
|
|
expect(result.changedFiles).toEqual(["b.txt", "c.txt"]);
|
|
});
|
|
|
|
it("runAutoPrerebase succeeds on clean history", async () => {
|
|
const { repo, a } = setupRepo();
|
|
const branch = "fusion/fn-4958-test";
|
|
git(repo, `git checkout -b ${branch} ${a}`);
|
|
writeFileSync(join(repo, "task.txt"), "task\n");
|
|
git(repo, "git add task.txt && git commit -m 'task'");
|
|
const mainHead = git(repo, "git rev-parse main");
|
|
git(repo, `git checkout ${branch}`);
|
|
|
|
const logs: string[] = [];
|
|
const result = await runAutoPrerebase({
|
|
rootDir: repo,
|
|
worktreePath: repo,
|
|
branch,
|
|
taskId: "FN-4958",
|
|
mainHead,
|
|
logger: { log: (m) => logs.push(m), warn: (m) => logs.push(m) },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(logs.some((m) => m.includes("succeeded"))).toBe(true);
|
|
});
|
|
|
|
it("runAutoPrerebase aborts and returns failure on conflict", async () => {
|
|
const { repo, a } = setupRepo();
|
|
const branch = "fusion/fn-4958-conflict";
|
|
git(repo, `git checkout -b ${branch} ${a}`);
|
|
writeFileSync(join(repo, "shared.txt"), "branch\n");
|
|
git(repo, "git add shared.txt && git commit -m 'branch-change'");
|
|
git(repo, "git checkout main");
|
|
writeFileSync(join(repo, "shared.txt"), "main\n");
|
|
git(repo, "git add shared.txt && git commit -m 'main-change'");
|
|
|
|
const mainHead = git(repo, "git rev-parse main");
|
|
git(repo, `git checkout ${branch}`);
|
|
|
|
const warn = vi.fn();
|
|
const result = await runAutoPrerebase({
|
|
rootDir: repo,
|
|
worktreePath: repo,
|
|
branch,
|
|
taskId: "FN-4958",
|
|
mainHead,
|
|
logger: { log: vi.fn(), warn },
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBeTruthy();
|
|
expect(warn).toHaveBeenCalled();
|
|
expect(git(repo, "git status --porcelain")).toBe("");
|
|
});
|
|
});
|