fix(FN-2855): scope task diff base to baseCommitSha when branch is null
When a dependency task merges and its branch is deleted, self-healing nulls the dependent task's baseBranch. Both resolveDiffBase (dashboard) and resolveTaskDiffBaseRef (merger) defaulted to "main" in that case, widening the diff range to merge-base(HEAD, main) and surfacing unrelated history — e.g. FN-2855 reported 108 changed files instead of 16. Skip the merge-base step when baseBranch is unset and a baseCommitSha is recorded; fall back to "main" only for legacy tasks lacking both hints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,31 @@ describe("resolveDiffBase", () => {
|
||||
expect(diffBase).toBe("origin-merge-base");
|
||||
});
|
||||
|
||||
it("uses baseCommitSha directly when baseBranch is null (upstream branch deleted)", async () => {
|
||||
// Regression: FN-2855 showed 108 changed files because the dashboard fell
|
||||
// back to merge-base(HEAD, main) after self-healing nulled the original
|
||||
// baseBranch. With a valid baseCommitSha recorded, we must skip the
|
||||
// "main" default and use the SHA so the diff range stays task-scoped.
|
||||
const runGit = vi.fn(async (args: string[]) => {
|
||||
if (args.join(" ") === "merge-base --is-ancestor task-base-789 HEAD") return "";
|
||||
throw new Error(`Unexpected command: ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
const diffBase = await resolveDiffBase(
|
||||
{ baseCommitSha: "task-base-789" },
|
||||
"/tmp/worktree",
|
||||
"HEAD",
|
||||
runGit,
|
||||
);
|
||||
|
||||
expect(diffBase).toBe("task-base-789");
|
||||
expect(runGit).not.toHaveBeenCalledWith(
|
||||
["merge-base", "HEAD", "main"],
|
||||
"/tmp/worktree",
|
||||
5000,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
|
||||
const runGit = vi.fn(async (args: string[]) => {
|
||||
if (args.join(" ") === "merge-base HEAD main") {
|
||||
|
||||
@@ -60,17 +60,24 @@ export async function resolveDiffBase(
|
||||
headRef = "HEAD",
|
||||
runGit: (args: string[], cwd?: string, timeout?: number) => Promise<string> = runGitCommand,
|
||||
): Promise<string | undefined> {
|
||||
const baseBranch = task.baseBranch ?? "main";
|
||||
// When baseBranch was nulled (e.g., upstream dep merged and its branch was
|
||||
// deleted) but a task-scoped baseCommitSha is still recorded, skip the
|
||||
// merge-base step so we don't widen the diff range to merge-base(HEAD, main)
|
||||
// and surface unrelated history. Only fall back to "main" when neither hint
|
||||
// is available (legacy tasks).
|
||||
const baseBranch = task.baseBranch?.trim() || (task.baseCommitSha ? undefined : "main");
|
||||
let mergeBase: string | undefined;
|
||||
|
||||
try {
|
||||
if (baseBranch) {
|
||||
try {
|
||||
mergeBase = (await runGit(["merge-base", headRef, baseBranch], cwd, 5000)).trim() || undefined;
|
||||
try {
|
||||
mergeBase = (await runGit(["merge-base", headRef, baseBranch], cwd, 5000)).trim() || undefined;
|
||||
} catch {
|
||||
mergeBase = (await runGit(["merge-base", headRef, `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
mergeBase = (await runGit(["merge-base", headRef, `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
|
||||
// base branch may no longer exist locally/remotely
|
||||
}
|
||||
} catch {
|
||||
// base branch may no longer exist locally/remotely
|
||||
}
|
||||
|
||||
// If merge-base equals headRef, the live merge-base would produce an empty
|
||||
|
||||
@@ -1287,26 +1287,33 @@ export async function resolveTaskDiffBaseRef({
|
||||
baseBranch,
|
||||
baseCommitSha,
|
||||
}: DiffBaseResolutionInput): Promise<string | undefined> {
|
||||
const resolvedBaseBranch = baseBranch?.trim() || "main";
|
||||
// When baseBranch was nulled (e.g., upstream dep merged and its branch was
|
||||
// deleted) but a task-scoped baseCommitSha is still recorded, skip the
|
||||
// merge-base step so we don't widen the diff range to merge-base(HEAD, main)
|
||||
// and surface unrelated history. Only fall back to "main" when neither hint
|
||||
// is available (legacy tasks).
|
||||
const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? undefined : "main");
|
||||
const quotedHeadRef = quoteArg(headRef);
|
||||
let mergeBase: string | undefined;
|
||||
|
||||
try {
|
||||
if (resolvedBaseBranch) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
mergeBase = stdout.trim() || undefined;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
mergeBase = stdout.trim() || undefined;
|
||||
} catch {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
mergeBase = stdout.trim() || undefined;
|
||||
}
|
||||
} catch {
|
||||
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
mergeBase = stdout.trim() || undefined;
|
||||
// Base branch may not exist locally/remotely.
|
||||
}
|
||||
} catch {
|
||||
// Base branch may not exist locally/remotely.
|
||||
}
|
||||
|
||||
// Same guard as dashboard routes: when merge-base === headRef, the range
|
||||
|
||||
Reference in New Issue
Block a user