feat(FN-4499): complete Step 2 — add bootstrap misbinding classifier
Fusion-Task-Id: FN-4499 Fusion-Task-Lineage: 3e9fee75-5c3d-4ce5-aa8b-3f2bb94e48eb
This commit is contained in:
@@ -6,6 +6,7 @@ import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
autoRecoverCrossContamination,
|
||||
classifyBootstrapMisbinding,
|
||||
classifyForeignCommits,
|
||||
type BranchCrossContaminationCommit,
|
||||
} from "../branch-conflicts.js";
|
||||
@@ -106,6 +107,82 @@ describe("branch contamination recovery classification", () => {
|
||||
expect(result.unique.map((entry) => entry.sha)).toEqual([uniqueCommit.sha]);
|
||||
});
|
||||
|
||||
it("classifies bootstrap misbinding when range has only foreign-attributed commits", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const foreign = await makeCommit(repoDir, "foreign-bootstrap", "feat(FN-4367): dependency change", "FN-4367");
|
||||
|
||||
const result = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4488",
|
||||
foreignCommits: [foreign],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
isBootstrapMisbinding: true,
|
||||
ownCommitCount: 0,
|
||||
nonAttributedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not classify bootstrap misbinding when an own-task commit exists", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const foreign = await makeCommit(repoDir, "foreign-mixed", "feat(FN-4367): dependency change", "FN-4367");
|
||||
await appendFile(path.join(repoDir, "note.txt"), "own\n", "utf-8");
|
||||
await run("git add note.txt", repoDir);
|
||||
await run("git commit -m 'feat(FN-4488): own work' -m 'Fusion-Task-Id: FN-4488'", repoDir);
|
||||
|
||||
const result = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4488",
|
||||
foreignCommits: [foreign],
|
||||
});
|
||||
|
||||
expect(result.isBootstrapMisbinding).toBe(false);
|
||||
expect(result.ownCommitCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does not classify bootstrap misbinding when non-attributed commits are present", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const foreign = await makeCommit(repoDir, "foreign-mixed-2", "feat(FN-4367): dependency change", "FN-4367");
|
||||
await appendFile(path.join(repoDir, "note.txt"), "refactor\n", "utf-8");
|
||||
await run("git add note.txt", repoDir);
|
||||
await run("git commit -m 'refactor: unattributed cleanup'", repoDir);
|
||||
|
||||
const result = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4488",
|
||||
foreignCommits: [foreign],
|
||||
});
|
||||
|
||||
expect(result.isBootstrapMisbinding).toBe(false);
|
||||
expect(result.ownCommitCount).toBe(0);
|
||||
expect(result.nonAttributedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns false for empty range with zero own and zero non-attributed commits", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
|
||||
const result = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4488",
|
||||
foreignCommits: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
isBootstrapMisbinding: false,
|
||||
ownCommitCount: 0,
|
||||
nonAttributedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-recovers by dropping already-upstream foreign commits and preserving remaining branch work", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
await writeFile(path.join(repoDir, "foreign.txt"), "", "utf-8");
|
||||
|
||||
@@ -340,6 +340,64 @@ export async function assertCleanBranchAtBase(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClassifyBootstrapMisbindingInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
baseSha: string;
|
||||
taskId: string;
|
||||
foreignCommits: BranchCrossContaminationCommit[];
|
||||
}
|
||||
|
||||
export interface ClassifyBootstrapMisbindingResult {
|
||||
isBootstrapMisbinding: boolean;
|
||||
ownCommitCount: number;
|
||||
nonAttributedCount: number;
|
||||
}
|
||||
|
||||
export async function classifyBootstrapMisbinding(
|
||||
input: ClassifyBootstrapMisbindingInput,
|
||||
): Promise<ClassifyBootstrapMisbindingResult> {
|
||||
const { repoDir, branchName, baseSha, taskId, foreignCommits } = input;
|
||||
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b ${quoteShellArg(`${baseSha}..${branchName}`)}`)
|
||||
.catch(() => "");
|
||||
if (!output) {
|
||||
return {
|
||||
isBootstrapMisbinding: false,
|
||||
ownCommitCount: 0,
|
||||
nonAttributedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`, "i");
|
||||
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*${escapedTaskId}\\s*(?:\\n|$)`, "i");
|
||||
const subjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
|
||||
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(FN-\d+)\s*(?:\n|$)/i;
|
||||
|
||||
let ownCommitCount = 0;
|
||||
let nonAttributedCount = 0;
|
||||
for (const line of output.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
|
||||
const [, subject = "", body = ""] = line.split("\u001f");
|
||||
if (ownSubjectPattern.test(subject) || ownTrailerPattern.test(body)) {
|
||||
ownCommitCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectMatch = subject.match(subjectPattern);
|
||||
const trailerMatch = body.match(trailerPattern);
|
||||
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
|
||||
if (!attributedTaskId) {
|
||||
nonAttributedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isBootstrapMisbinding: foreignCommits.length > 0 && ownCommitCount === 0 && nonAttributedCount === 0,
|
||||
ownCommitCount,
|
||||
nonAttributedCount,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClassifyForeignCommitsInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
|
||||
@@ -97,6 +97,7 @@ export {
|
||||
BranchConflictError,
|
||||
BranchCrossContaminationError,
|
||||
assertCleanBranchAtBase,
|
||||
classifyBootstrapMisbinding,
|
||||
isBranchConflictError,
|
||||
inspectBranchConflict,
|
||||
listBranchRecoveryCandidates,
|
||||
|
||||
Reference in New Issue
Block a user