feat(FN-4428): complete Step 2 — classify foreign commits
Fusion-Task-Id: FN-4428 Fusion-Task-Lineage: ea174f50-49ac-4105-b6cf-db3bab0984fd
This commit is contained in:
107
packages/engine/src/__tests__/branch-conflicts-recovery.test.ts
Normal file
107
packages/engine/src/__tests__/branch-conflicts-recovery.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
classifyForeignCommits,
|
||||
type BranchCrossContaminationCommit,
|
||||
} from "../branch-conflicts.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
async function run(command: string, cwd: string): Promise<string> {
|
||||
const { stdout } = await execAsync(command, { cwd, encoding: "utf-8" });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
describe("branch contamination recovery classification", () => {
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function setupRepo() {
|
||||
const repoDir = await mkdtemp(path.join(tmpdir(), "fn-4428-"));
|
||||
dirs.push(repoDir);
|
||||
|
||||
await run("git init -b main", repoDir);
|
||||
await run("git config user.email test@example.com", repoDir);
|
||||
await run("git config user.name 'Test User'", repoDir);
|
||||
|
||||
await writeFile(path.join(repoDir, "note.txt"), "base\n", "utf-8");
|
||||
await run("git add note.txt && git commit -m 'chore: base'", repoDir);
|
||||
const baseSha = await run("git rev-parse HEAD", repoDir);
|
||||
|
||||
await run("git checkout -b feature", repoDir);
|
||||
|
||||
return { repoDir, baseSha };
|
||||
}
|
||||
|
||||
async function makeCommit(repoDir: string, body: string, subject: string, foreignTaskId: string): Promise<BranchCrossContaminationCommit> {
|
||||
await appendFile(path.join(repoDir, "note.txt"), `${body}\n`, "utf-8");
|
||||
await run("git add note.txt", repoDir);
|
||||
await run(`git commit -m ${JSON.stringify(subject)} -m ${JSON.stringify(`Fusion-Task-Id: ${foreignTaskId}`)}`, repoDir);
|
||||
const sha = await run("git rev-parse HEAD", repoDir);
|
||||
return { sha, subject, foreignTaskId };
|
||||
}
|
||||
|
||||
it("classifies all foreign commits as already-upstream when patches exist on main", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
|
||||
const commit = await makeCommit(repoDir, "foreign-a", "feat(FN-4412): foreign change", "FN-4412");
|
||||
await run("git checkout main", repoDir);
|
||||
await run(`git cherry-pick ${commit.sha}`, repoDir);
|
||||
await run("git checkout feature", repoDir);
|
||||
|
||||
const result = await classifyForeignCommits({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
foreignCommits: [commit],
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.alreadyUpstream.map((entry) => entry.sha)).toEqual([commit.sha]);
|
||||
expect(result.unique).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies all foreign commits as unique when patches are absent on main", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const commit = await makeCommit(repoDir, "foreign-b", "feat(FN-4412): unique", "FN-4412");
|
||||
|
||||
const result = await classifyForeignCommits({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
foreignCommits: [commit],
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.alreadyUpstream).toEqual([]);
|
||||
expect(result.unique.map((entry) => entry.sha)).toEqual([commit.sha]);
|
||||
});
|
||||
|
||||
it("classifies mixed foreign commits into already-upstream and unique buckets", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const upstreamCommit = await makeCommit(repoDir, "foreign-c", "feat(FN-4412): upstream", "FN-4412");
|
||||
const uniqueCommit = await makeCommit(repoDir, "foreign-d", "fix(FN-4410): still unique", "FN-4410");
|
||||
|
||||
await run("git checkout main", repoDir);
|
||||
await run(`git cherry-pick ${upstreamCommit.sha}`, repoDir);
|
||||
await run("git checkout feature", repoDir);
|
||||
|
||||
const result = await classifyForeignCommits({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
foreignCommits: [upstreamCommit, uniqueCommit],
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.alreadyUpstream.map((entry) => entry.sha)).toEqual([upstreamCommit.sha]);
|
||||
expect(result.unique.map((entry) => entry.sha)).toEqual([uniqueCommit.sha]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
|
||||
const GIT_TIMEOUT_MS = 120_000;
|
||||
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
export interface BranchConflictCommit {
|
||||
sha: string;
|
||||
@@ -116,6 +118,8 @@ async function runGit(repoDir: string, command: string): Promise<string> {
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
@@ -259,6 +263,152 @@ export async function assertCleanBranchAtBase(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClassifyForeignCommitsInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
baseSha: string;
|
||||
foreignCommits: BranchCrossContaminationCommit[];
|
||||
mainRef?: string;
|
||||
}
|
||||
|
||||
export interface ClassifyForeignCommitsResult {
|
||||
/**
|
||||
* Commits whose patch-id already exists on main and are safe to drop.
|
||||
*/
|
||||
alreadyUpstream: BranchCrossContaminationCommit[];
|
||||
/**
|
||||
* Commits whose patch-id is unique and require human adjudication.
|
||||
*/
|
||||
unique: BranchCrossContaminationCommit[];
|
||||
}
|
||||
|
||||
export async function classifyForeignCommits(
|
||||
input: ClassifyForeignCommitsInput,
|
||||
): Promise<ClassifyForeignCommitsResult> {
|
||||
const { repoDir, branchName, baseSha, foreignCommits, mainRef = "main" } = input;
|
||||
const targetBySha = new Map(foreignCommits.map((commit) => [commit.sha, commit]));
|
||||
if (targetBySha.size === 0) {
|
||||
return { alreadyUpstream: [], unique: [] };
|
||||
}
|
||||
|
||||
const classifyFromCherryOutput = (output: string): ClassifyForeignCommitsResult => {
|
||||
const alreadyUpstreamSha = new Set<string>();
|
||||
const uniqueSha = new Set<string>();
|
||||
const resolveFullSha = (token: string): string | null => {
|
||||
if (targetBySha.has(token)) return token;
|
||||
const match = foreignCommits.find((commit) => commit.sha.startsWith(token));
|
||||
return match?.sha ?? null;
|
||||
};
|
||||
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const [marker, token] = line.split(/\s+/, 2);
|
||||
if (!token) continue;
|
||||
const sha = resolveFullSha(token);
|
||||
if (!sha) continue;
|
||||
if (marker === "-") {
|
||||
alreadyUpstreamSha.add(sha);
|
||||
} else if (marker === "+") {
|
||||
uniqueSha.add(sha);
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyUpstream = foreignCommits.filter((commit) => alreadyUpstreamSha.has(commit.sha) || !uniqueSha.has(commit.sha));
|
||||
const unique = foreignCommits.filter((commit) => uniqueSha.has(commit.sha));
|
||||
return { alreadyUpstream, unique };
|
||||
};
|
||||
|
||||
try {
|
||||
const comparisonBase = baseSha || await runGit(repoDir, `git merge-base ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)}`);
|
||||
const output = await runGit(repoDir, `git cherry ${quoteShellArg(mainRef)} ${quoteShellArg(branchName)} ${quoteShellArg(comparisonBase)}`);
|
||||
return classifyFromCherryOutput(output);
|
||||
} catch {
|
||||
const upstreamPatchIdsOutput = await runGit(
|
||||
repoDir,
|
||||
`git rev-list ${quoteShellArg(mainRef)} | while read c; do git show "$c" | git patch-id --stable; done`,
|
||||
).catch(() => "");
|
||||
const upstreamPatchIds = new Set(
|
||||
upstreamPatchIdsOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(" ")[0])
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const alreadyUpstream: BranchCrossContaminationCommit[] = [];
|
||||
const unique: BranchCrossContaminationCommit[] = [];
|
||||
for (const commit of foreignCommits) {
|
||||
const patchIdLine = await runGit(repoDir, `git show ${quoteShellArg(commit.sha)} | git patch-id --stable`).catch(() => "");
|
||||
const patchId = patchIdLine.trim().split(" ")[0];
|
||||
if (patchId && upstreamPatchIds.has(patchId)) {
|
||||
alreadyUpstream.push(commit);
|
||||
} else {
|
||||
unique.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
return { alreadyUpstream, unique };
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutoRecoverCrossContaminationInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
baseSha: string;
|
||||
taskId: string;
|
||||
alreadyUpstreamShas: string[];
|
||||
mainRef?: string;
|
||||
}
|
||||
|
||||
export interface AutoRecoverCrossContaminationResult {
|
||||
newTipSha: string;
|
||||
droppedShas: string[];
|
||||
}
|
||||
|
||||
export async function autoRecoverCrossContamination(
|
||||
input: AutoRecoverCrossContaminationInput,
|
||||
): Promise<AutoRecoverCrossContaminationResult> {
|
||||
const { repoDir, branchName, baseSha, taskId, alreadyUpstreamShas } = input;
|
||||
const dropSet = new Set(alreadyUpstreamShas);
|
||||
if (dropSet.size === 0) {
|
||||
throw new Error("autoRecoverCrossContamination requires at least one already-upstream SHA");
|
||||
}
|
||||
|
||||
const originalTip = await revParse(repoDir, branchName);
|
||||
const commitListOutput = await runGit(repoDir, `git rev-list --reverse ${quoteShellArg(`${baseSha}..${branchName}`)}`)
|
||||
.catch(() => "");
|
||||
const commits = commitListOutput.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
await runGit(repoDir, `git checkout --detach ${quoteShellArg(baseSha)}`);
|
||||
|
||||
try {
|
||||
for (const sha of commits) {
|
||||
if (dropSet.has(sha)) continue;
|
||||
await execAsync(`git cherry-pick ${quoteShellArg(sha)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
}
|
||||
|
||||
const newTip = await revParse(repoDir, "HEAD");
|
||||
await runGit(repoDir, `git update-ref ${quoteShellArg(`refs/heads/${branchName}`)} ${quoteShellArg(newTip)} ${quoteShellArg(originalTip)}`);
|
||||
await runGit(repoDir, `git checkout ${quoteShellArg(branchName)}`);
|
||||
} catch (error) {
|
||||
await runGit(repoDir, `git cherry-pick --abort`).catch(() => undefined);
|
||||
await runGit(repoDir, `git checkout ${quoteShellArg(branchName)}`).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await assertCleanBranchAtBase(repoDir, branchName, baseSha, taskId);
|
||||
|
||||
return {
|
||||
newTipSha: await revParse(repoDir, branchName),
|
||||
droppedShas: Array.from(dropSet),
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectBranchConflict(
|
||||
input: InspectBranchConflictInput,
|
||||
): Promise<BranchConflictInspectionResult> {
|
||||
|
||||
Reference in New Issue
Block a user