feat(FN-4958): complete Step 2 — add auto-prerebase helper module
Fusion-Task-Id: FN-4958 Fusion-Task-Lineage: 2f1bc742-14e0-4fdd-a980-00051fc3323c
This commit is contained in:
committed by
gsxdsm
parent
106499c375
commit
760690c588
135
packages/engine/src/__tests__/merger-auto-prerebase.test.ts
Normal file
135
packages/engine/src/__tests__/merger-auto-prerebase.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 when nothing triggers", () => {
|
||||
const decision = decideAutoPrerebase({ settings, baseCommitSha: "abc", commitsBehind: 5, changedFiles: ["x.ts"], 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("");
|
||||
});
|
||||
});
|
||||
120
packages/engine/src/merger-auto-prerebase.ts
Normal file
120
packages/engine/src/merger-auto-prerebase.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProjectSettings } from "@fusion/core";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface AutoPrerebaseDecision {
|
||||
fire: boolean;
|
||||
reason: "disabled" | "no-base" | "no-divergence" | "worktrunk-deferred" | "hot-file" | "divergence-threshold";
|
||||
commitsBehind: number;
|
||||
hotMatches: string[];
|
||||
}
|
||||
|
||||
export async function probeDivergence(opts: {
|
||||
rootDir: string;
|
||||
baseCommitSha: string;
|
||||
mainRef?: string;
|
||||
}): Promise<{ commitsBehind: number; changedFiles: string[] }> {
|
||||
const mainRef = opts.mainRef ?? "HEAD";
|
||||
const range = `${opts.baseCommitSha}..${mainRef}`;
|
||||
const [{ stdout: countOut }, { stdout: diffOut }] = await Promise.all([
|
||||
execAsync(`git rev-list --count ${range}`, {
|
||||
cwd: opts.rootDir,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}),
|
||||
execAsync(`git diff --name-only ${range}`, {
|
||||
cwd: opts.rootDir,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}),
|
||||
]);
|
||||
|
||||
const commitsBehind = Number.parseInt(countOut.trim() || "0", 10);
|
||||
const changedFiles = diffOut
|
||||
.split("\n")
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
commitsBehind: Number.isFinite(commitsBehind) ? commitsBehind : 0,
|
||||
changedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function decideAutoPrerebase(input: {
|
||||
settings: ProjectSettings;
|
||||
baseCommitSha: string | null | undefined;
|
||||
commitsBehind: number;
|
||||
changedFiles: string[];
|
||||
worktrunkEnabled: boolean;
|
||||
}): AutoPrerebaseDecision {
|
||||
const commitsBehind = Math.max(0, input.commitsBehind || 0);
|
||||
if (input.worktrunkEnabled) {
|
||||
return { fire: false, reason: "worktrunk-deferred", commitsBehind, hotMatches: [] };
|
||||
}
|
||||
if (input.settings.prerebaseAutoEnabled === false) {
|
||||
return { fire: false, reason: "disabled", commitsBehind, hotMatches: [] };
|
||||
}
|
||||
if (!input.baseCommitSha) {
|
||||
return { fire: false, reason: "no-base", commitsBehind, hotMatches: [] };
|
||||
}
|
||||
|
||||
const hotFiles = input.settings.prerebaseHotFiles ?? [];
|
||||
const hotSet = new Set(hotFiles);
|
||||
const hotMatches = input.changedFiles.filter((path) => hotSet.has(path));
|
||||
if (hotMatches.length > 0) {
|
||||
return { fire: true, reason: "hot-file", commitsBehind, hotMatches };
|
||||
}
|
||||
|
||||
const threshold = input.settings.prerebaseDivergenceThreshold ?? 0;
|
||||
if (threshold > 0 && commitsBehind > threshold) {
|
||||
return { fire: true, reason: "divergence-threshold", commitsBehind, hotMatches: [] };
|
||||
}
|
||||
|
||||
return { fire: false, reason: "no-divergence", commitsBehind, hotMatches: [] };
|
||||
}
|
||||
|
||||
export async function runAutoPrerebase(deps: {
|
||||
rootDir: string;
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
taskId: string;
|
||||
mainHead?: string;
|
||||
logger: { log: (m: string) => void; warn: (m: string) => void };
|
||||
}): Promise<{ ok: boolean; mainHead: string; error?: string }> {
|
||||
const mainHead = deps.mainHead?.trim() || (await execAsync("git rev-parse HEAD", {
|
||||
cwd: deps.rootDir,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})).stdout.trim();
|
||||
|
||||
try {
|
||||
await execAsync(`git rebase "${mainHead}"`, {
|
||||
cwd: deps.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
deps.logger.log(`${deps.taskId}: auto-prerebase succeeded (${deps.branch} -> ${mainHead.slice(0, 8)})`);
|
||||
return { ok: true, mainHead };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logger.warn(`${deps.taskId}: auto-prerebase failed (${message}) — aborting`);
|
||||
try {
|
||||
await execAsync("git rebase --abort", {
|
||||
cwd: deps.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch (abortError: unknown) {
|
||||
deps.logger.warn(`${deps.taskId}: auto-prerebase abort failed (${abortError instanceof Error ? abortError.message : String(abortError)})`);
|
||||
}
|
||||
return { ok: false, mainHead, error: message };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user