feat(FN-3217): honor resolved merge target across completion flows

- Add merge target branch resolver contract to core types and exports
- Update task lifecycle completion path to resolve and propagate merge target branch
- Use resolved merge target in merger execution instead of stale/default branch assumptions
- Expand core and CLI tests for merge-target resolution behavior and add changeset for @runfusion/fusion

Fusion-Task-Id: FN-3217
This commit is contained in:
Fusion
2026-05-09 23:06:18 -07:00
committed by gsxdsm
parent 2598bed041
commit bb9816b8c8
9 changed files with 150 additions and 34 deletions

View File

@@ -33,6 +33,7 @@ import { hostname } from "node:os";
import {
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
resolveTaskMergeTarget,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt,
@@ -4600,6 +4601,11 @@ export async function aiMergeTask(
// sites in mergeAttempt + attemptWithSideStrategy.
let mergeWasEmpty = false;
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
// 3. Check branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
@@ -4622,6 +4628,8 @@ export async function aiMergeTask(
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
},
});
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`);
@@ -4632,9 +4640,8 @@ export async function aiMergeTask(
return result;
}
// 3b. Ensure rootDir is on the main branch before merging.
// Without this, a merge could land on whatever branch was last checked out,
// causing feature code to be committed to the wrong lineage.
// 3b. Ensure rootDir is on the resolved merge target before merging.
// Without this, a merge could land on whatever branch was last checked out.
try {
throwIfAborted(options.signal, taskId);
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
@@ -4642,32 +4649,16 @@ export async function aiMergeTask(
encoding: "utf-8",
stdio: "pipe",
}).trim();
const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim().replace(/^origin\//, "");
if (currentBranch !== mainBranch) {
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mainBranch}' before merge`);
await execAsync(`git checkout "${mainBranch}"`, {
if (currentBranch !== mergeTarget.branch) {
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mergeTarget.branch}' before merge (${mergeTarget.source})`);
await execAsync(`git checkout "${mergeTarget.branch}"`, {
cwd: rootDir,
});
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: mainBranch });
await audit.git({ type: "branch:checkout", target: mergeTarget.branch });
}
} catch (error: unknown) {
rethrowIfMergeAborted(error);
// Fallback: try checking out main directly
try {
throwIfAborted(options.signal, taskId);
await execAsync("git checkout main", { cwd: rootDir });
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: "main" });
} catch (fallbackError: unknown) {
rethrowIfMergeAborted(fallbackError);
mergerLog.warn(`${taskId}: unable to verify/checkout main branch — proceeding on current HEAD`);
}
mergerLog.warn(`${taskId}: unable to verify/checkout merge target '${mergeTarget.branch}' — proceeding on current HEAD`);
}
// 3c. Pre-merge remote rebase.
@@ -5739,6 +5730,8 @@ export async function aiMergeTask(
mergeCommitMessage: aiMergeSummary || commitLog,
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
resolutionStrategy: result.resolutionStrategy,
resolutionMethod: result.resolutionMethod,
attemptsMade: result.attemptsMade,
@@ -5756,7 +5749,7 @@ export async function aiMergeTask(
if (recordedSha) {
summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
} else if (mergeWasEmpty) {
summaryParts.push("no commit landed (branch already on main)");
summaryParts.push(`no commit landed (branch already on ${mergeTarget.branch})`);
} else if (isEmptyCommit) {
summaryParts.push("squash collapsed to empty (sha deferred)");
}