feat(FN-4508): complete Step 1 — harden inspectBranchConflict ghost/tip guards
Fusion-Task-Id: FN-4508 Fusion-Task-Lineage: 948a9cae-3975-4f15-bd47-2f88b379171d
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { appendFile, mkdtemp, mkdir, 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 { inspectBranchConflict } 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("inspectBranchConflict ghost references", () => {
|
||||
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-4508-branch-conflict-"));
|
||||
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);
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
it("returns stale-resolved when live branch mapping points to missing ghost path", async () => {
|
||||
const repoDir = await setupRepo();
|
||||
await run("git checkout -b fusion/fn-9999", repoDir);
|
||||
await run("git checkout main", repoDir);
|
||||
const livePath = path.join(repoDir, ".worktrees/ghost-cat");
|
||||
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9999`, repoDir);
|
||||
await rm(livePath, { recursive: true, force: true });
|
||||
const conflictingPath = path.join(repoDir, "conflict-path");
|
||||
await mkdir(conflictingPath, { recursive: true });
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName: "fusion/fn-9999",
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: "FN-9999",
|
||||
ownerTaskId: "FN-9999",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("stale-resolved");
|
||||
});
|
||||
|
||||
it("returns tip-already-merged when branch tip is reachable from main despite stale startPoint", async () => {
|
||||
const repoDir = await setupRepo();
|
||||
const staleStartPoint = await run("git rev-parse HEAD", repoDir);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await appendFile(path.join(repoDir, "note.txt"), `m${i}\n`, "utf-8");
|
||||
await run(`git add note.txt && git commit -m 'chore: main-${i}'`, repoDir);
|
||||
}
|
||||
await run("git branch fusion/fn-9999", repoDir);
|
||||
const livePath = path.join(repoDir, "wt-live");
|
||||
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9999`, repoDir);
|
||||
const conflictingPath = path.join(repoDir, "conflict-live");
|
||||
await mkdir(conflictingPath, { recursive: true });
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName: "fusion/fn-9999",
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: "FN-9999",
|
||||
ownerTaskId: "FN-9999",
|
||||
startPoint: staleStartPoint,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("tip-already-merged");
|
||||
if (result.kind === "tip-already-merged") {
|
||||
expect(result.integrationRef).toBe("main");
|
||||
expect(result.tipSha).toBe(await run("git rev-parse fusion/fn-9999", repoDir));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns tip-already-merged when startPoint is HEAD and tip is ancestor", async () => {
|
||||
const repoDir = await setupRepo();
|
||||
await run("git branch fusion/fn-9999", repoDir);
|
||||
const livePath = path.join(repoDir, "wt-head");
|
||||
await run(`git worktree add ${JSON.stringify(livePath)} fusion/fn-9999`, repoDir);
|
||||
const conflictingPath = path.join(repoDir, "conflict-head");
|
||||
await mkdir(conflictingPath, { recursive: true });
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName: "fusion/fn-9999",
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: "FN-9999",
|
||||
ownerTaskId: "FN-9999",
|
||||
startPoint: "HEAD",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("tip-already-merged");
|
||||
});
|
||||
|
||||
it("keeps genuine live-foreign conflicts unchanged", async () => {
|
||||
const repoDir = await setupRepo();
|
||||
await run("git checkout -b topic/other", repoDir);
|
||||
await appendFile(path.join(repoDir, "note.txt"), "foreign\n", "utf-8");
|
||||
await run("git add note.txt", repoDir);
|
||||
await run("git commit -m 'chore: foreign work'", repoDir);
|
||||
await run("git checkout main", repoDir);
|
||||
const livePath = path.join(repoDir, "wt-foreign");
|
||||
await run(`git worktree add ${JSON.stringify(livePath)} topic/other`, repoDir);
|
||||
const conflictingPath = path.join(repoDir, "conflict-foreign");
|
||||
await mkdir(conflictingPath, { recursive: true });
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName: "topic/other",
|
||||
conflictingWorktreePath: conflictingPath,
|
||||
requestingTaskId: "FN-9999",
|
||||
ownerTaskId: "FN-9999",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live-foreign");
|
||||
if (result.kind === "live-foreign") {
|
||||
expect(result.error.name).toBe("BranchConflictError");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps stale conflictingWorktreePath short-circuit behavior", async () => {
|
||||
const repoDir = await setupRepo();
|
||||
await run("git branch fusion/fn-9999", repoDir);
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir,
|
||||
branchName: "fusion/fn-9999",
|
||||
conflictingWorktreePath: path.join(repoDir, "missing-conflict-path"),
|
||||
requestingTaskId: "FN-9999",
|
||||
ownerTaskId: "FN-9999",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("stale");
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ export interface InspectBranchConflictInput {
|
||||
export type BranchConflictInspectionResult =
|
||||
| { kind: "stale" }
|
||||
| { kind: "stale-resolved" }
|
||||
| { kind: "tip-already-merged"; livePath: string | null; tipSha: string; integrationRef: string }
|
||||
| { kind: "fully-subsumed"; livePath: string; tipSha: string }
|
||||
| { kind: "reclaimable"; livePath: string; tipSha: string; taskAttributedCommitCount: number; strandedCommits: BranchConflictCommit[] }
|
||||
| { kind: "live-foreign"; livePath: string; error: BranchConflictError };
|
||||
@@ -136,6 +137,20 @@ async function revParse(repoDir: string, ref: string): Promise<string> {
|
||||
return runGit(repoDir, `git rev-parse --verify ${quoteShellArg(`${ref}^{commit}`)}`);
|
||||
}
|
||||
|
||||
async function isAncestor(repoDir: string, sha: string, ref: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git merge-base --is-ancestor ${quoteShellArg(sha)} ${quoteShellArg(ref)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function listStrandedCommits(repoDir: string, startPoint: string, branchName: string): Promise<BranchConflictCommit[]> {
|
||||
try {
|
||||
const output = await runGit(
|
||||
@@ -675,8 +690,8 @@ export async function inspectBranchConflict(
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const worktreeMap = await getWorktreeBranchMap(input.repoDir);
|
||||
const livePath = worktreeMap.get(input.branchName);
|
||||
let worktreeMap = await getWorktreeBranchMap(input.repoDir);
|
||||
let livePath = worktreeMap.get(input.branchName);
|
||||
|
||||
try {
|
||||
await revParse(input.repoDir, `refs/heads/${input.branchName}`);
|
||||
@@ -684,11 +699,32 @@ export async function inspectBranchConflict(
|
||||
return { kind: "stale-resolved" };
|
||||
}
|
||||
|
||||
if (livePath && !existsSync(livePath)) {
|
||||
try {
|
||||
await runGit(input.repoDir, "git worktree prune");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
worktreeMap = await getWorktreeBranchMap(input.repoDir);
|
||||
const refreshedLivePath = worktreeMap.get(input.branchName);
|
||||
livePath = refreshedLivePath && existsSync(refreshedLivePath) ? refreshedLivePath : undefined;
|
||||
}
|
||||
|
||||
if (!livePath) {
|
||||
return { kind: "stale-resolved" };
|
||||
}
|
||||
|
||||
const existingTipSha = await revParse(input.repoDir, input.branchName);
|
||||
const integrationRef = await resolveBranchComparisonRef(input.repoDir, "main", input.branchName);
|
||||
if (await isAncestor(input.repoDir, existingTipSha, integrationRef)) {
|
||||
return {
|
||||
kind: "tip-already-merged",
|
||||
livePath: livePath ?? null,
|
||||
tipSha: existingTipSha,
|
||||
integrationRef,
|
||||
};
|
||||
}
|
||||
|
||||
const uniqueCommitResult = await listUniqueBranchCommits(input.repoDir, startPoint, input.branchName);
|
||||
const attribution = await summarizeTaskAttributedCommits(
|
||||
input.repoDir,
|
||||
|
||||
Reference in New Issue
Block a user