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:
Fusion
2026-05-14 15:58:15 -07:00
committed by gsxdsm
parent 98f7a28130
commit 663e5d2cdc
2 changed files with 185 additions and 2 deletions

View File

@@ -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");
});
});

View File

@@ -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,