feat(FN-4485): complete Step 2 — tighten branch conflict classifier
Fusion-Task-Id: FN-4485 Fusion-Task-Lineage: 034088dc-ebc4-4e12-8314-39419d41b23f
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExecException } from "node:child_process";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify } = await import("node:util");
|
||||
const execSyncFn = vi.fn();
|
||||
|
||||
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : (opts ?? {});
|
||||
try {
|
||||
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err) {
|
||||
if (typeof callback === "function") {
|
||||
const error = err as ExecException & { stdout?: string; stderr?: string };
|
||||
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
execFn[promisify.custom] = (cmd: string, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
|
||||
if (err) {
|
||||
(err as Record<string, unknown>).stdout = stdout;
|
||||
(err as Record<string, unknown>).stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { exec: execFn, execSync: execSyncFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { inspectBranchConflict } from "../branch-conflicts.js";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
|
||||
describe("branch-conflicts self-owned classifier", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns reclaimable for self-owned branch with zero task-attributed commits", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/wt-fn-4485", "HEAD 222", "branch refs/heads/fusion/fn-4485", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4485^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4485^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) return Buffer.from("mainsha\n");
|
||||
if (command === "git merge-base 'main' 'fusion/fn-4485'") return Buffer.from("base123\n");
|
||||
if (command === "git cherry 'main' 'fusion/fn-4485' 'base123'") return Buffer.from("+ aaa111\n");
|
||||
if (command.includes("git rev-parse --verify 'aaa111^{commit}'")) return Buffer.from("aaa111\n");
|
||||
if (command === "git log -1 --format=%s 'aaa111'") return Buffer.from("prior work\n");
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4485'")) {
|
||||
return Buffer.from("aaa111\u0000chore: prior work\u0000\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4485",
|
||||
conflictingWorktreePath: "/tmp/wt-fn-4485",
|
||||
requestingTaskId: "FN-4485",
|
||||
ownerTaskId: "FN-4485",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("reclaimable");
|
||||
if (result.kind !== "reclaimable") throw new Error("expected reclaimable");
|
||||
expect(result.taskAttributedCommitCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns reclaimable for self-owned branch with positive task-attributed commits", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/wt-fn-4485", "HEAD 222", "branch refs/heads/fusion/fn-4485", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4485^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4485^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) return Buffer.from("mainsha\n");
|
||||
if (command === "git merge-base 'main' 'fusion/fn-4485'") return Buffer.from("base123\n");
|
||||
if (command === "git cherry 'main' 'fusion/fn-4485' 'base123'") return Buffer.from("+ aaa111\n");
|
||||
if (command.includes("git rev-parse --verify 'aaa111^{commit}'")) return Buffer.from("aaa111\n");
|
||||
if (command === "git log -1 --format=%s 'aaa111'") return Buffer.from("owned work\n");
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4485'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-4485): owned\u0000Fusion-Task-Id: FN-4485\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4485",
|
||||
conflictingWorktreePath: "/tmp/wt-fn-4485",
|
||||
requestingTaskId: "FN-4485",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("reclaimable");
|
||||
if (result.kind !== "reclaimable") throw new Error("expected reclaimable");
|
||||
expect(result.taskAttributedCommitCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps foreign worktree conflicts as live-foreign", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/wt-fn-9999", "HEAD 222", "branch refs/heads/topic/other", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/topic/other^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'topic/other^{commit}'")) return Buffer.from("tipsha\n");
|
||||
if (command.includes("git rev-parse --verify 'main^{commit}'")) return Buffer.from("mainsha\n");
|
||||
if (command === "git merge-base 'main' 'topic/other'") return Buffer.from("base123\n");
|
||||
if (command === "git cherry 'main' 'topic/other' 'base123'") return Buffer.from("+ aaa111\n");
|
||||
if (command.includes("git rev-parse --verify 'aaa111^{commit}'")) return Buffer.from("aaa111\n");
|
||||
if (command === "git log -1 --format=%s 'aaa111'") return Buffer.from("foreign work\n");
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..topic/other'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-9999): foreign\u0000Fusion-Task-Id: FN-9999\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "topic/other",
|
||||
conflictingWorktreePath: "/tmp/requesting-wt",
|
||||
requestingTaskId: "FN-4485",
|
||||
ownerTaskId: "FN-4485",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live-foreign");
|
||||
});
|
||||
|
||||
it("keeps stale-resolved when branch ref is gone", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/wt", "HEAD 222", "branch refs/heads/main", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4485^{commit}'")) {
|
||||
throw new Error("missing");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4485",
|
||||
conflictingWorktreePath: "/tmp/wt-fn-4485",
|
||||
requestingTaskId: "FN-4485",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "stale-resolved" });
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ export interface InspectBranchConflictInput {
|
||||
branchName: string;
|
||||
conflictingWorktreePath: string;
|
||||
requestingTaskId: string;
|
||||
ownerTaskId?: string;
|
||||
startPoint?: string;
|
||||
}
|
||||
|
||||
@@ -270,28 +271,45 @@ export async function listBranchRecoveryCandidates(
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function countTaskAttributedCommits(repoDir: string, range: string, taskId: string): Promise<number> {
|
||||
interface TaskAttributionSummary {
|
||||
ownCount: number;
|
||||
foreignCount: number;
|
||||
}
|
||||
|
||||
async function summarizeTaskAttributedCommits(repoDir: string, range: string, taskId: string): Promise<TaskAttributionSummary> {
|
||||
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const subjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`);
|
||||
const trailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}(?:\\n|$)`);
|
||||
const ownSubjectPattern = new RegExp(`^(feat|fix|test|chore|docs|refactor|perf|build)\\(${escapedTaskId}\\):`);
|
||||
const ownTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}: ${escapedTaskId}(?:\\n|$)`);
|
||||
const genericSubjectPattern = /^(feat|fix|test|chore|docs|refactor|perf|build)\((FN-\d+)\):/i;
|
||||
const genericTrailerPattern = new RegExp(`(?:^|\\n)${FUSION_TASK_ID_TRAILER_KEY}:\\s*(FN-\\d+)(?:\\n|$)`, "i");
|
||||
let output = "";
|
||||
try {
|
||||
output = await runGit(repoDir, `git log --format=%H%x00%s%x00%b ${quoteShellArg(range)}`);
|
||||
} catch {
|
||||
return 0;
|
||||
return { ownCount: 0, foreignCount: 0 };
|
||||
}
|
||||
if (!output) return 0;
|
||||
if (!output) return { ownCount: 0, foreignCount: 0 };
|
||||
|
||||
const normalizedTaskId = taskId.toUpperCase();
|
||||
const tokens = output.split("\u0000");
|
||||
let count = 0;
|
||||
let ownCount = 0;
|
||||
let foreignCount = 0;
|
||||
for (let i = 0; i + 2 < tokens.length; i += 3) {
|
||||
const subject = tokens[i + 1] ?? "";
|
||||
const body = tokens[i + 2] ?? "";
|
||||
if (subjectPattern.test(subject) || trailerPattern.test(body)) {
|
||||
count += 1;
|
||||
if (ownSubjectPattern.test(subject) || ownTrailerPattern.test(body)) {
|
||||
ownCount += 1;
|
||||
continue;
|
||||
}
|
||||
const subjectMatch = subject.match(genericSubjectPattern);
|
||||
const trailerMatch = body.match(genericTrailerPattern);
|
||||
const attributedTaskId = (trailerMatch?.[1] ?? subjectMatch?.[2] ?? "").toUpperCase();
|
||||
if (attributedTaskId && attributedTaskId !== normalizedTaskId) {
|
||||
foreignCount += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
|
||||
return { ownCount, foreignCount };
|
||||
}
|
||||
|
||||
export async function assertCleanBranchAtBase(
|
||||
@@ -489,6 +507,12 @@ export async function autoRecoverCrossContamination(
|
||||
};
|
||||
}
|
||||
|
||||
function deriveTaskIdFromFusionBranch(branchName: string): string | null {
|
||||
const match = /^fusion\/(fn-\d+)$/i.exec(branchName.trim());
|
||||
if (!match) return null;
|
||||
return match[1].toUpperCase();
|
||||
}
|
||||
|
||||
export async function inspectBranchConflict(
|
||||
input: InspectBranchConflictInput,
|
||||
): Promise<BranchConflictInspectionResult> {
|
||||
@@ -518,11 +542,12 @@ export async function inspectBranchConflict(
|
||||
|
||||
const existingTipSha = await revParse(input.repoDir, input.branchName);
|
||||
const uniqueCommitResult = await listUniqueBranchCommits(input.repoDir, startPoint, input.branchName);
|
||||
const taskAttributedCommitCount = await countTaskAttributedCommits(
|
||||
const attribution = await summarizeTaskAttributedCommits(
|
||||
input.repoDir,
|
||||
`${startPoint}..${input.branchName}`,
|
||||
input.requestingTaskId,
|
||||
);
|
||||
const taskAttributedCommitCount = attribution.ownCount;
|
||||
|
||||
if (!uniqueCommitResult.degraded && uniqueCommitResult.commits.length === 0) {
|
||||
return {
|
||||
@@ -532,7 +557,13 @@ export async function inspectBranchConflict(
|
||||
};
|
||||
}
|
||||
|
||||
if (taskAttributedCommitCount > 0) {
|
||||
const normalizedOwnerTaskId = (input.ownerTaskId ?? input.requestingTaskId).trim().toUpperCase();
|
||||
const branchOwnerTaskId = deriveTaskIdFromFusionBranch(input.branchName);
|
||||
const isSelfOwnedWorktree =
|
||||
livePath === input.conflictingWorktreePath ||
|
||||
(branchOwnerTaskId !== null && branchOwnerTaskId === normalizedOwnerTaskId);
|
||||
|
||||
if (taskAttributedCommitCount > 0 || (isSelfOwnedWorktree && attribution.foreignCount === 0)) {
|
||||
return {
|
||||
kind: "reclaimable",
|
||||
livePath,
|
||||
|
||||
@@ -7048,6 +7048,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
branchName: error.branchName,
|
||||
conflictingWorktreePath: error.conflictingWorktreePath,
|
||||
requestingTaskId: task.id,
|
||||
ownerTaskId: task.id,
|
||||
startPoint: error.startPoint,
|
||||
});
|
||||
|
||||
@@ -7737,6 +7738,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
branchName: branch,
|
||||
conflictingWorktreePath: conflictPath,
|
||||
requestingTaskId: taskId,
|
||||
ownerTaskId: taskId,
|
||||
startPoint,
|
||||
});
|
||||
|
||||
|
||||
@@ -1435,6 +1435,7 @@ export class SelfHealingManager {
|
||||
branchName: task.branch,
|
||||
conflictingWorktreePath: task.worktree,
|
||||
requestingTaskId: task.id,
|
||||
ownerTaskId: task.id,
|
||||
startPoint: task.baseCommitSha ?? task.mergeDetails?.mergeTargetBranch ?? "main",
|
||||
});
|
||||
|
||||
|
||||
@@ -264,6 +264,7 @@ export class WorktreePool {
|
||||
branchName,
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: options?.requestingTaskId ?? taskId,
|
||||
ownerTaskId: taskId,
|
||||
startPoint: base,
|
||||
});
|
||||
if (inspection.kind === "stale" || inspection.kind === "stale-resolved") {
|
||||
|
||||
Reference in New Issue
Block a user