feat(FN-4948): complete Step 4 — classify and drop misrouted foreign commits
Fusion-Task-Id: FN-4948 Fusion-Task-Lineage: dc622643-4c3e-4217-9219-a6a6e5424427
This commit is contained in:
committed by
gsxdsm
parent
925877ba12
commit
597aa79690
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { classifyMisroutedForeignCommit } from "../branch-conflicts.js";
|
||||
|
||||
function git(dir: string, cmd: string): string {
|
||||
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
}
|
||||
|
||||
describe("classifyMisroutedForeignCommit", () => {
|
||||
it("classifies trailer-attributed .changeset-only commit as misrouted", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fn-4948-misrouted-"));
|
||||
try {
|
||||
git(dir, "git init -b main");
|
||||
git(dir, 'git config user.email "test@example.com"');
|
||||
git(dir, 'git config user.name "Test"');
|
||||
writeFileSync(join(dir, "README.md"), "init\n");
|
||||
git(dir, "git add README.md && git commit -m 'init'");
|
||||
mkdirSync(join(dir, ".changeset"), { recursive: true });
|
||||
writeFileSync(join(dir, ".changeset", "fn-1234-fix.md"), "patch\n");
|
||||
git(dir, "git add .changeset/fn-1234-fix.md");
|
||||
git(dir, "git commit -m 'chore: changeset only' -m 'Fusion-Task-Id: FN-1234'");
|
||||
const sha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await classifyMisroutedForeignCommit({
|
||||
repoDir: dir,
|
||||
sha,
|
||||
commitSubject: "chore: changeset only",
|
||||
commitBody: "Fusion-Task-Id: FN-1234",
|
||||
currentTaskId: "FN-8888",
|
||||
});
|
||||
|
||||
expect(result.misrouted).toBe(true);
|
||||
expect(result.foreignTaskId).toBe("FN-1234");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies subject-only task attribution and normalizes case", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fn-4948-misrouted-subject-"));
|
||||
try {
|
||||
git(dir, "git init -b main");
|
||||
git(dir, 'git config user.email "test@example.com"');
|
||||
git(dir, 'git config user.name "Test"');
|
||||
git(dir, "git commit --allow-empty -m init");
|
||||
mkdirSync(join(dir, ".changeset"), { recursive: true });
|
||||
writeFileSync(join(dir, ".changeset", "fn-7777-feature.md"), "minor\n");
|
||||
git(dir, "git add .changeset/fn-7777-feature.md && git commit -m 'feat(fn-7777): add feature' ");
|
||||
const sha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await classifyMisroutedForeignCommit({
|
||||
repoDir: dir,
|
||||
sha,
|
||||
commitSubject: "feat(fn-7777): add feature",
|
||||
commitBody: "",
|
||||
currentTaskId: "FN-0001",
|
||||
});
|
||||
|
||||
expect(result.misrouted).toBe(true);
|
||||
expect(result.foreignTaskId).toBe("FN-7777");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not classify misrouted when shared paths are present", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fn-4948-misrouted-shared-"));
|
||||
try {
|
||||
git(dir, "git init -b main");
|
||||
git(dir, 'git config user.email "test@example.com"');
|
||||
git(dir, 'git config user.name "Test"');
|
||||
writeFileSync(join(dir, "README.md"), "init\n");
|
||||
git(dir, "git add README.md && git commit -m 'init'");
|
||||
mkdirSync(join(dir, ".changeset"), { recursive: true });
|
||||
mkdirSync(join(dir, "packages", "engine", "src"), { recursive: true });
|
||||
writeFileSync(join(dir, ".changeset", "fn-4321-fix.md"), "patch\n");
|
||||
writeFileSync(join(dir, "packages", "engine", "src", "executor.ts"), "x\n");
|
||||
git(dir, "git add .changeset/fn-4321-fix.md packages/engine/src/executor.ts");
|
||||
git(dir, "git commit -m 'fix(FN-4321): mixed paths' -m 'Fusion-Task-Id: FN-4321'");
|
||||
const sha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await classifyMisroutedForeignCommit({
|
||||
repoDir: dir,
|
||||
sha,
|
||||
commitSubject: "fix(FN-4321): mixed paths",
|
||||
commitBody: "Fusion-Task-Id: FN-4321",
|
||||
currentTaskId: "FN-1111",
|
||||
});
|
||||
|
||||
expect(result.misrouted).toBe(false);
|
||||
expect(result.foreignTaskId).toBe("FN-4321");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not classify when attribution matches current task", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fn-4948-misrouted-same-"));
|
||||
try {
|
||||
git(dir, "git init -b main");
|
||||
git(dir, 'git config user.email "test@example.com"');
|
||||
git(dir, 'git config user.name "Test"');
|
||||
git(dir, "git commit --allow-empty -m init");
|
||||
mkdirSync(join(dir, ".changeset"), { recursive: true });
|
||||
writeFileSync(join(dir, ".changeset", "fn-9000-fix.md"), "patch\n");
|
||||
git(dir, "git add .changeset/fn-9000-fix.md && git commit -m 'test(FN-9000): same task' ");
|
||||
const sha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
const result = await classifyMisroutedForeignCommit({
|
||||
repoDir: dir,
|
||||
sha,
|
||||
commitSubject: "test(FN-9000): same task",
|
||||
commitBody: "",
|
||||
currentTaskId: "fn-9000",
|
||||
});
|
||||
|
||||
expect(result.misrouted).toBe(false);
|
||||
expect(result.foreignTaskId).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -697,7 +697,7 @@ export interface AutoRecoverCrossContaminationInput {
|
||||
branchName: string;
|
||||
baseSha: string;
|
||||
taskId: string;
|
||||
alreadyUpstreamShas: string[];
|
||||
shasToDrop: string[];
|
||||
mainRef?: string;
|
||||
}
|
||||
|
||||
@@ -709,10 +709,10 @@ export interface AutoRecoverCrossContaminationResult {
|
||||
export async function autoRecoverCrossContamination(
|
||||
input: AutoRecoverCrossContaminationInput,
|
||||
): Promise<AutoRecoverCrossContaminationResult> {
|
||||
const { repoDir, branchName, baseSha, taskId, alreadyUpstreamShas } = input;
|
||||
const dropSet = new Set(alreadyUpstreamShas);
|
||||
const { repoDir, branchName, baseSha, taskId, shasToDrop } = input;
|
||||
const dropSet = new Set(shasToDrop);
|
||||
if (dropSet.size === 0) {
|
||||
throw new Error("autoRecoverCrossContamination requires at least one already-upstream SHA");
|
||||
throw new Error("autoRecoverCrossContamination requires at least one SHA to drop");
|
||||
}
|
||||
|
||||
const originalTip = await revParse(repoDir, branchName);
|
||||
|
||||
@@ -4564,17 +4564,35 @@ export class TaskExecutor {
|
||||
foreignCommits: err.foreignCommits,
|
||||
});
|
||||
|
||||
const misrouted: Array<{ commit: (typeof classified.unique)[number]; foreignTaskId: string; paths: string[] }> = [];
|
||||
const genuinelyUnique: typeof classified.unique = [];
|
||||
for (const commit of classified.unique) {
|
||||
const misroutedResult = await classifyMisroutedForeignCommit({
|
||||
repoDir: this.rootDir,
|
||||
sha: commit.sha,
|
||||
commitSubject: commit.subject,
|
||||
commitBody: await execAsync(`git log -1 --format=%b ${commit.sha}`, { cwd: this.rootDir, encoding: "utf-8" }).then((r) => r.stdout).catch(() => ""),
|
||||
currentTaskId: task.id,
|
||||
});
|
||||
if (misroutedResult.misrouted && misroutedResult.foreignTaskId) {
|
||||
misrouted.push({ commit, foreignTaskId: misroutedResult.foreignTaskId, paths: misroutedResult.paths ?? [] });
|
||||
} else {
|
||||
genuinelyUnique.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyShas = classified.alreadyUpstream.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
|
||||
const uniqueShas = classified.unique.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
|
||||
const misroutedShas = misrouted.map(({ commit }) => commit.sha.slice(0, 12)).join(", ") || "none";
|
||||
const uniqueShas = genuinelyUnique.map((commit) => commit.sha.slice(0, 12)).join(", ") || "none";
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] contamination classification: already-upstream=[${alreadyShas}] unique=[${uniqueShas}]`,
|
||||
`[recovery] contamination classification: already-upstream=[${alreadyShas}] misrouted=[${misroutedShas}] unique=[${uniqueShas}]`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
|
||||
const alreadyAttemptedRecovery = (task.recoveryRetryCount ?? 0) > 0;
|
||||
if (classified.unique.length === 0 && !alreadyAttemptedRecovery) {
|
||||
if (genuinelyUnique.length === 0 && !alreadyAttemptedRecovery) {
|
||||
// Run the recovery inside the worktree (when one exists) so the final
|
||||
// `git checkout <branch>` step doesn't collide with the worktree's own
|
||||
// checkout. If we operate from this.rootDir while the branch is checked
|
||||
@@ -4588,16 +4606,31 @@ export class TaskExecutor {
|
||||
branchName: err.branchName,
|
||||
baseSha: err.baseSha,
|
||||
taskId: task.id,
|
||||
alreadyUpstreamShas: classified.alreadyUpstream.map((commit) => commit.sha),
|
||||
shasToDrop: [
|
||||
...classified.alreadyUpstream.map((commit) => commit.sha),
|
||||
...misrouted.map(({ commit }) => commit.sha),
|
||||
],
|
||||
});
|
||||
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] auto-recovered branch-cross-contamination: dropped ${recovery.droppedShas.length} already-upstream commits (SHAs: ${recovery.droppedShas.map((sha) => sha.slice(0, 12)).join(", ")}); new tip ${recovery.newTipSha.slice(0, 12)}`,
|
||||
`[recovery] auto-recovered branch-cross-contamination: dropped ${recovery.droppedShas.length} commits (already-upstream + misrouted, SHAs: ${recovery.droppedShas.map((sha) => sha.slice(0, 12)).join(", ")}); new tip ${recovery.newTipSha.slice(0, 12)}`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
|
||||
for (const dropped of misrouted) {
|
||||
await audit.database({
|
||||
type: "task:auto-recover-misrouted-foreign-commit",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
droppedSha: dropped.commit.sha,
|
||||
foreignTaskId: dropped.foreignTaskId,
|
||||
paths: dropped.paths,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
recoveryRetryCount: 1,
|
||||
nextRecoveryAt: null,
|
||||
@@ -4626,10 +4659,10 @@ export class TaskExecutor {
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
} else if (classified.unique.length > 0) {
|
||||
} else if (genuinelyUnique.length > 0) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] unique foreign commits require human adjudication: ${classified.unique.map((commit) => commit.sha.slice(0, 12)).join(", ")}`,
|
||||
`[recovery] unique foreign commits require human adjudication: ${genuinelyUnique.map((commit) => commit.sha.slice(0, 12)).join(", ")}`,
|
||||
undefined,
|
||||
this.currentRunContext,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user