feat(FN-4887): complete Step 1 — add foreign-only contamination classifier
Fusion-Task-Id: FN-4887 Fusion-Task-Lineage: 559690d8-cea6-4323-a522-9ebb6aa25731
This commit is contained in:
committed by
gsxdsm
parent
100d6494d9
commit
91df093502
@@ -0,0 +1,140 @@
|
||||
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 { classifyForeignOnlyContamination } 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("classifyForeignOnlyContamination", () => {
|
||||
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-4887-"));
|
||||
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, line: string, subject: string, trailerTaskId?: string) {
|
||||
await appendFile(path.join(repoDir, "note.txt"), `${line}\n`, "utf-8");
|
||||
await run("git add note.txt", repoDir);
|
||||
if (trailerTaskId) {
|
||||
await run(`git commit -m ${JSON.stringify(subject)} -m ${JSON.stringify(`Fusion-Task-Id: ${trailerTaskId}`)}`, repoDir);
|
||||
} else {
|
||||
await run(`git commit -m ${JSON.stringify(subject)}`, repoDir);
|
||||
}
|
||||
return run("git rev-parse HEAD", repoDir);
|
||||
}
|
||||
|
||||
it("returns foreign-only-no-own-work when only foreign-attributed commits exist", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const foreignSha = await makeCommit(repoDir, "foreign-a", "feat(FN-4001): foreign", "FN-4001");
|
||||
|
||||
const result = await classifyForeignOnlyContamination({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4887",
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("foreign-only-no-own-work");
|
||||
expect(result.ownCommitCount).toBe(0);
|
||||
expect(result.nonAttributedCount).toBe(0);
|
||||
expect(result.foreignCommitCount).toBe(1);
|
||||
expect(result.uniqueShas).toEqual([foreignSha]);
|
||||
});
|
||||
|
||||
it("returns foreign-only-already-upstream when foreign-attributed commits are on main", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
const foreignSha = await makeCommit(repoDir, "foreign-b", "feat(FN-4002): foreign upstream", "FN-4002");
|
||||
await run("git checkout main", repoDir);
|
||||
await run(`git cherry-pick ${foreignSha}`, repoDir);
|
||||
await run("git checkout feature", repoDir);
|
||||
|
||||
const result = await classifyForeignOnlyContamination({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4887",
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("foreign-only-already-upstream");
|
||||
expect(result.foreignCommitCount).toBe(1);
|
||||
expect(result.uniqueShas).toEqual([]);
|
||||
expect(result.alreadyUpstreamShas).toEqual([foreignSha]);
|
||||
});
|
||||
|
||||
it("returns ambiguous when own and foreign commits are mixed", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
await makeCommit(repoDir, "foreign-c", "feat(FN-4003): foreign", "FN-4003");
|
||||
await makeCommit(repoDir, "own", "feat(FN-4887): own", "FN-4887");
|
||||
|
||||
const result = await classifyForeignOnlyContamination({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4887",
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("ambiguous");
|
||||
expect(result.ownCommitCount).toBe(1);
|
||||
expect(result.foreignCommitCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns ambiguous when non-attributed commits exist", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
await makeCommit(repoDir, "foreign-d", "feat(FN-4004): foreign", "FN-4004");
|
||||
await makeCommit(repoDir, "plain", "refactor: plain unattributed");
|
||||
|
||||
const result = await classifyForeignOnlyContamination({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4887",
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("ambiguous");
|
||||
expect(result.nonAttributedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns clean when branch has no foreign commits", async () => {
|
||||
const { repoDir, baseSha } = await setupRepo();
|
||||
await makeCommit(repoDir, "own-clean", "feat(FN-4887): own", "FN-4887");
|
||||
|
||||
const result = await classifyForeignOnlyContamination({
|
||||
repoDir,
|
||||
branchName: "feature",
|
||||
baseSha,
|
||||
taskId: "FN-4887",
|
||||
mainRef: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("clean");
|
||||
expect(result.foreignCommitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -432,6 +432,29 @@ export interface ClassifyForeignCommitsResult {
|
||||
unique: BranchCrossContaminationCommit[];
|
||||
}
|
||||
|
||||
export type ForeignOnlyContaminationKind =
|
||||
| "foreign-only-no-own-work"
|
||||
| "foreign-only-already-upstream"
|
||||
| "ambiguous"
|
||||
| "clean";
|
||||
|
||||
export interface ClassifyForeignOnlyContaminationInput {
|
||||
repoDir: string;
|
||||
branchName: string;
|
||||
baseSha: string;
|
||||
taskId: string;
|
||||
mainRef?: string;
|
||||
}
|
||||
|
||||
export interface ClassifyForeignOnlyContaminationResult {
|
||||
kind: ForeignOnlyContaminationKind;
|
||||
ownCommitCount: number;
|
||||
foreignCommitCount: number;
|
||||
nonAttributedCount: number;
|
||||
alreadyUpstreamShas: string[];
|
||||
uniqueShas: string[];
|
||||
}
|
||||
|
||||
async function classifyForeignCommitsViaPatchId(
|
||||
repoDir: string,
|
||||
mainRef: string,
|
||||
@@ -522,6 +545,77 @@ export async function classifyForeignCommits(
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyForeignOnlyContamination(
|
||||
input: ClassifyForeignOnlyContaminationInput,
|
||||
): Promise<ClassifyForeignOnlyContaminationResult> {
|
||||
const { repoDir, branchName, baseSha, taskId, mainRef = "main" } = input;
|
||||
const output = await runGit(repoDir, `git log --format=%H%x1f%s%x1f%b ${quoteShellArg(`${baseSha}..${branchName}`)}`)
|
||||
.catch(() => "");
|
||||
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;
|
||||
const foreignCommits: BranchCrossContaminationCommit[] = [];
|
||||
for (const line of output.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
|
||||
const [sha, subject, body] = line.split("\u001f");
|
||||
const subjectMatch = (subject ?? "").match(subjectPattern);
|
||||
const trailerMatch = (body ?? "").match(trailerPattern);
|
||||
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
|
||||
if (attributedTaskId && attributedTaskId !== taskId.toUpperCase()) {
|
||||
foreignCommits.push({ sha, subject: subject ?? "", foreignTaskId: attributedTaskId });
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap = await classifyBootstrapMisbinding({
|
||||
repoDir,
|
||||
branchName,
|
||||
baseSha,
|
||||
taskId,
|
||||
foreignCommits,
|
||||
});
|
||||
|
||||
if (foreignCommits.length === 0) {
|
||||
return {
|
||||
kind: "clean",
|
||||
ownCommitCount: bootstrap.ownCommitCount,
|
||||
foreignCommitCount: 0,
|
||||
nonAttributedCount: bootstrap.nonAttributedCount,
|
||||
alreadyUpstreamShas: [],
|
||||
uniqueShas: [],
|
||||
};
|
||||
}
|
||||
|
||||
const foreignClassification = await classifyForeignCommits({
|
||||
repoDir,
|
||||
branchName,
|
||||
baseSha,
|
||||
foreignCommits,
|
||||
mainRef,
|
||||
});
|
||||
|
||||
const result: ClassifyForeignOnlyContaminationResult = {
|
||||
kind: "ambiguous",
|
||||
ownCommitCount: bootstrap.ownCommitCount,
|
||||
foreignCommitCount: foreignCommits.length,
|
||||
nonAttributedCount: bootstrap.nonAttributedCount,
|
||||
alreadyUpstreamShas: foreignClassification.alreadyUpstream.map((entry) => entry.sha),
|
||||
uniqueShas: foreignClassification.unique.map((entry) => entry.sha),
|
||||
};
|
||||
|
||||
if (result.ownCommitCount === 0 && result.nonAttributedCount === 0 && result.foreignCommitCount > 0) {
|
||||
result.kind = result.uniqueShas.length === 0
|
||||
? "foreign-only-already-upstream"
|
||||
: "foreign-only-no-own-work";
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.ownCommitCount > 0 || result.nonAttributedCount > 0) {
|
||||
result.kind = "ambiguous";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.kind = "clean";
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ReanchorBranchToBaseInput {
|
||||
repoDir: string;
|
||||
worktreePath: string;
|
||||
|
||||
Reference in New Issue
Block a user