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:
5
.changeset/fn-3217-merge-target-branch.md
Normal file
5
.changeset/fn-3217-merge-target-branch.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Honor task-configured merge targets across CLI and merge completion paths, including PR creation base branch selection and merge metadata resolution.
|
||||||
@@ -901,6 +901,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
title: "FN-093: Add support for creating pull requests",
|
title: "FN-093: Add support for creating pull requests",
|
||||||
body: "Automated PR for FN-093.\n\nImplement PR automation",
|
body: "Automated PR for FN-093.\n\nImplement PR automation",
|
||||||
head: "fusion/fn-093",
|
head: "fusion/fn-093",
|
||||||
|
base: "main",
|
||||||
});
|
});
|
||||||
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
expect(store.updatePrInfo).toHaveBeenCalledWith(
|
||||||
"FN-093",
|
"FN-093",
|
||||||
@@ -1126,6 +1127,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
|
|||||||
title: "FN-093: Task",
|
title: "FN-093: Task",
|
||||||
body: "Automated PR for FN-093.\n\nDescription",
|
body: "Automated PR for FN-093.\n\nDescription",
|
||||||
head: "fusion/fn-093",
|
head: "fusion/fn-093",
|
||||||
|
base: "main",
|
||||||
});
|
});
|
||||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1154,6 +1156,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
|
|||||||
title: "FN-093: Task",
|
title: "FN-093: Task",
|
||||||
body: "Automated PR for FN-093.\n\nDescription",
|
body: "Automated PR for FN-093.\n\nDescription",
|
||||||
head: "fusion/fn-093",
|
head: "fusion/fn-093",
|
||||||
|
base: "main",
|
||||||
});
|
});
|
||||||
expect(aiMergeTask).not.toHaveBeenCalled();
|
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { exec } from "node:child_process";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { resolveTaskMergeTarget } from "@fusion/core";
|
||||||
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,7 @@ import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
|||||||
*/
|
*/
|
||||||
interface GitHubOperations {
|
interface GitHubOperations {
|
||||||
findPrForBranch(params: { head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
|
findPrForBranch(params: { head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
|
||||||
createPr(params: { title: string; body: string; head: string }): Promise<PrInfo>;
|
createPr(params: { title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
|
||||||
getPrMergeStatus(base?: string, head?: string, number?: number): Promise<{
|
getPrMergeStatus(base?: string, head?: string, number?: number): Promise<{
|
||||||
prInfo: PrInfo;
|
prInfo: PrInfo;
|
||||||
reviewDecision: string | null;
|
reviewDecision: string | null;
|
||||||
@@ -228,6 +229,11 @@ export async function processPullRequestMergeTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branch = getTaskBranchName(task.id);
|
const branch = getTaskBranchName(task.id);
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||||
|
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||||
|
projectDefaultBranch,
|
||||||
|
});
|
||||||
let prInfo: PrInfo | undefined = task.prInfo;
|
let prInfo: PrInfo | undefined = task.prInfo;
|
||||||
|
|
||||||
if (!prInfo) {
|
if (!prInfo) {
|
||||||
@@ -245,6 +251,7 @@ export async function processPullRequestMergeTask(
|
|||||||
title: buildPullRequestTitle(task),
|
title: buildPullRequestTitle(task),
|
||||||
body: buildPullRequestBody(task),
|
body: buildPullRequestBody(task),
|
||||||
head: branch,
|
head: branch,
|
||||||
|
base: mergeTarget.branch,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -269,7 +276,7 @@ export async function processPullRequestMergeTask(
|
|||||||
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
|
const mergeStatus = await github.getPrMergeStatus(mergeTarget.branch, branch, prInfo.number);
|
||||||
const refreshedPrInfo: PrInfo = {
|
const refreshedPrInfo: PrInfo = {
|
||||||
...prInfo,
|
...prInfo,
|
||||||
...mergeStatus.prInfo,
|
...mergeStatus.prInfo,
|
||||||
@@ -288,7 +295,6 @@ export async function processPullRequestMergeTask(
|
|||||||
// immediately. `requirePrApproval` lets users keep PR mode as "open the
|
// immediately. `requirePrApproval` lets users keep PR mode as "open the
|
||||||
// PR, wait for me to approve and merge it" by holding the merge until
|
// PR, wait for me to approve and merge it" by holding the merge until
|
||||||
// reviewDecision === "APPROVED".
|
// reviewDecision === "APPROVED".
|
||||||
const settings = await store.getSettings();
|
|
||||||
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
|
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
|
||||||
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
|
||||||
return "waiting";
|
return "waiting";
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { StepStatus } from "../types.js";
|
import type { StepStatus } from "../types.js";
|
||||||
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "../task-merge.js";
|
import {
|
||||||
|
getTaskCompletionBlocker,
|
||||||
|
getTaskMergeBlocker,
|
||||||
|
isTaskReadyForMerge,
|
||||||
|
resolveTaskMergeTarget,
|
||||||
|
} from "../task-merge.js";
|
||||||
|
|
||||||
const baseTask = {
|
const baseTask = {
|
||||||
column: "in-review" as const,
|
column: "in-review" as const,
|
||||||
@@ -16,6 +21,47 @@ const baseCompletionTask = {
|
|||||||
blockedBy: undefined as string | undefined,
|
blockedBy: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("resolveTaskMergeTarget", () => {
|
||||||
|
it("prefers task baseBranch when present", () => {
|
||||||
|
expect(resolveTaskMergeTarget({ baseBranch: "release/1.2", branchContext: undefined })).toEqual({
|
||||||
|
branch: "release/1.2",
|
||||||
|
source: "task-base-branch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to inherited branch context", () => {
|
||||||
|
expect(resolveTaskMergeTarget({
|
||||||
|
baseBranch: undefined,
|
||||||
|
branchContext: {
|
||||||
|
groupId: "G-1",
|
||||||
|
source: "planning",
|
||||||
|
assignmentMode: "shared",
|
||||||
|
inheritedBaseBranch: "develop",
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
branch: "develop",
|
||||||
|
source: "task-branch-context",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses project default branch when task has no explicit target", () => {
|
||||||
|
expect(resolveTaskMergeTarget(
|
||||||
|
{ baseBranch: undefined, branchContext: undefined },
|
||||||
|
{ projectDefaultBranch: "trunk" },
|
||||||
|
)).toEqual({
|
||||||
|
branch: "trunk",
|
||||||
|
source: "project-default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy main when no target is configured", () => {
|
||||||
|
expect(resolveTaskMergeTarget({ baseBranch: undefined, branchContext: undefined })).toEqual({
|
||||||
|
branch: "main",
|
||||||
|
source: "legacy-main",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getTaskMergeBlocker", () => {
|
describe("getTaskMergeBlocker", () => {
|
||||||
it("returns undefined for a clean task in review", () => {
|
it("returns undefined for a clean task in review", () => {
|
||||||
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();
|
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();
|
||||||
|
|||||||
@@ -87,7 +87,14 @@ export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDae
|
|||||||
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
||||||
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
export {
|
||||||
|
getTaskMergeBlocker,
|
||||||
|
getTaskCompletionBlocker,
|
||||||
|
isTaskReadyForMerge,
|
||||||
|
resolveTaskMergeTarget,
|
||||||
|
type MergeTargetResolution,
|
||||||
|
type MergeTargetResolverOptions,
|
||||||
|
} from "./task-merge.js";
|
||||||
export {
|
export {
|
||||||
isGhAvailable,
|
isGhAvailable,
|
||||||
isGhAuthenticated,
|
isGhAuthenticated,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { TodoStore } from "./todo-store.js";
|
|||||||
import { EvalStore } from "./eval-store.js";
|
import { EvalStore } from "./eval-store.js";
|
||||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||||
import { CentralCore } from "./central-core.js";
|
import { CentralCore } from "./central-core.js";
|
||||||
import { getTaskMergeBlocker } from "./task-merge.js";
|
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
|
||||||
import { ensureMemoryFileWithBackend } from "./project-memory.js";
|
import { ensureMemoryFileWithBackend } from "./project-memory.js";
|
||||||
import { runCommandAsync } from "./run-command.js";
|
import { runCommandAsync } from "./run-command.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
@@ -4487,7 +4487,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return clearedIds;
|
return clearedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): Promise<import("./types.js").MergeDetails> {
|
private async collectMergeDetails(
|
||||||
|
_id: string,
|
||||||
|
_branch: string,
|
||||||
|
task: Task,
|
||||||
|
commitMessage: string,
|
||||||
|
mergeTarget?: { branch: string; source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main" },
|
||||||
|
): Promise<import("./types.js").MergeDetails> {
|
||||||
const mergedAt = new Date().toISOString();
|
const mergedAt = new Date().toISOString();
|
||||||
let commitSha: string | undefined;
|
let commitSha: string | undefined;
|
||||||
let filesChanged: number | undefined;
|
let filesChanged: number | undefined;
|
||||||
@@ -4526,6 +4532,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
mergedAt,
|
mergedAt,
|
||||||
mergeConfirmed: true,
|
mergeConfirmed: true,
|
||||||
prNumber: task.prInfo?.number,
|
prNumber: task.prInfo?.number,
|
||||||
|
mergeTargetBranch: mergeTarget?.branch,
|
||||||
|
mergeTargetSource: mergeTarget?.source,
|
||||||
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
|
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
|
||||||
resolutionMethod: task.mergeDetails?.resolutionMethod,
|
resolutionMethod: task.mergeDetails?.resolutionMethod,
|
||||||
attemptsMade: task.mergeDetails?.attemptsMade,
|
attemptsMade: task.mergeDetails?.attemptsMade,
|
||||||
@@ -4541,7 +4549,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return this.withTaskLock(id, async () => {
|
return this.withTaskLock(id, async () => {
|
||||||
const dir = this.taskDir(id);
|
const dir = this.taskDir(id);
|
||||||
const task = await this.readTaskJson(dir);
|
const task = await this.readTaskJson(dir);
|
||||||
const branch = `fusion/${id.toLowerCase()}`;
|
const branch = task.branch || `fusion/${id.toLowerCase()}`;
|
||||||
// Branch is derived from the task id (already validated at create time),
|
// Branch is derived from the task id (already validated at create time),
|
||||||
// but assert as defense-in-depth against future id-format changes.
|
// but assert as defense-in-depth against future id-format changes.
|
||||||
assertSafeGitBranchName(branch);
|
assertSafeGitBranchName(branch);
|
||||||
@@ -4601,6 +4609,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
branchDeleted: false,
|
branchDeleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||||
|
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||||
|
projectDefaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Check the branch exists
|
// 1. Check the branch exists
|
||||||
const verifyBranch = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
|
const verifyBranch = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
|
||||||
if (verifyBranch.exitCode !== 0) {
|
if (verifyBranch.exitCode !== 0) {
|
||||||
@@ -4610,6 +4624,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
mergedAt: new Date().toISOString(),
|
mergedAt: new Date().toISOString(),
|
||||||
mergeConfirmed: false,
|
mergeConfirmed: false,
|
||||||
prNumber: task.prInfo?.number,
|
prNumber: task.prInfo?.number,
|
||||||
|
mergeTargetBranch: mergeTarget.branch,
|
||||||
|
mergeTargetSource: mergeTarget.source,
|
||||||
};
|
};
|
||||||
await this.moveToDone(task, dir);
|
await this.moveToDone(task, dir);
|
||||||
result.task = { ...task, column: "done" };
|
result.task = { ...task, column: "done" };
|
||||||
@@ -4617,6 +4633,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkoutTarget = await this.runGitCommand(`git checkout "${mergeTarget.branch}"`, 120_000);
|
||||||
|
if (checkoutTarget.exitCode !== 0) {
|
||||||
|
throw new Error(`Unable to checkout merge target branch '${mergeTarget.branch}' for ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Merge the branch
|
// 2. Merge the branch
|
||||||
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
|
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
|
||||||
const merge = await this.runGitCommand(`git merge --squash "${branch}"`, 120_000);
|
const merge = await this.runGitCommand(`git merge --squash "${branch}"`, 120_000);
|
||||||
@@ -4626,7 +4647,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
if (merge.exitCode === 0 && commit.exitCode === 0) {
|
if (merge.exitCode === 0 && commit.exitCode === 0) {
|
||||||
result.merged = true;
|
result.merged = true;
|
||||||
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage);
|
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage, mergeTarget);
|
||||||
task.mergeDetails = mergeDetails;
|
task.mergeDetails = mergeDetails;
|
||||||
Object.assign(result, mergeDetails);
|
Object.assign(result, mergeDetails);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { Task, WorkflowStepResult } from "./types.js";
|
import type { Task, WorkflowStepResult } from "./types.js";
|
||||||
|
|
||||||
|
export interface MergeTargetResolution {
|
||||||
|
branch: string;
|
||||||
|
source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeTargetResolverOptions {
|
||||||
|
projectDefaultBranch?: string;
|
||||||
|
legacyFallbackBranch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTaskMergeTarget(
|
||||||
|
task: Pick<Task, "baseBranch" | "branchContext">,
|
||||||
|
options: MergeTargetResolverOptions = {},
|
||||||
|
): MergeTargetResolution {
|
||||||
|
const configuredBase = task.baseBranch?.trim();
|
||||||
|
if (configuredBase) {
|
||||||
|
return { branch: configuredBase, source: "task-base-branch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inheritedBase = task.branchContext?.inheritedBaseBranch?.trim();
|
||||||
|
if (inheritedBase) {
|
||||||
|
return { branch: inheritedBase, source: "task-branch-context" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectDefault = options.projectDefaultBranch?.trim();
|
||||||
|
if (projectDefault) {
|
||||||
|
return { branch: projectDefault, source: "project-default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyFallback = options.legacyFallbackBranch?.trim() || "main";
|
||||||
|
return { branch: legacyFallback, source: "legacy-main" };
|
||||||
|
}
|
||||||
|
|
||||||
const BLOCKING_TASK_STATUSES = new Set([
|
const BLOCKING_TASK_STATUSES = new Set([
|
||||||
"failed",
|
"failed",
|
||||||
// ── User-attention / awaiting-handoff states ─────────────────────────
|
// ── User-attention / awaiting-handoff states ─────────────────────────
|
||||||
|
|||||||
@@ -989,6 +989,8 @@ export interface MergeDetails {
|
|||||||
mergedAt?: string;
|
mergedAt?: string;
|
||||||
mergeConfirmed?: boolean;
|
mergeConfirmed?: boolean;
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
|
mergeTargetBranch?: string;
|
||||||
|
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
|
||||||
resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort";
|
resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort";
|
||||||
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
|
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
|
||||||
attemptsMade?: 1 | 2 | 3;
|
attemptsMade?: 1 | 2 | 3;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { hostname } from "node:os";
|
|||||||
import {
|
import {
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
normalizeMergeConflictStrategy,
|
normalizeMergeConflictStrategy,
|
||||||
|
resolveTaskMergeTarget,
|
||||||
resolveProjectDefaultModel,
|
resolveProjectDefaultModel,
|
||||||
resolveTitleSummarizerSettingsModel,
|
resolveTitleSummarizerSettingsModel,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
@@ -4600,6 +4601,11 @@ export async function aiMergeTask(
|
|||||||
// sites in mergeAttempt + attemptWithSideStrategy.
|
// sites in mergeAttempt + attemptWithSideStrategy.
|
||||||
let mergeWasEmpty = false;
|
let mergeWasEmpty = false;
|
||||||
|
|
||||||
|
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
|
||||||
|
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||||
|
projectDefaultBranch,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Check branch exists
|
// 3. Check branch exists
|
||||||
try {
|
try {
|
||||||
execSync(`git rev-parse --verify "${branch}"`, {
|
execSync(`git rev-parse --verify "${branch}"`, {
|
||||||
@@ -4622,6 +4628,8 @@ export async function aiMergeTask(
|
|||||||
mergedAt: new Date().toISOString(),
|
mergedAt: new Date().toISOString(),
|
||||||
mergeConfirmed: true,
|
mergeConfirmed: true,
|
||||||
prNumber: task.prInfo?.number,
|
prNumber: task.prInfo?.number,
|
||||||
|
mergeTargetBranch: mergeTarget.branch,
|
||||||
|
mergeTargetSource: mergeTarget.source,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`);
|
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`);
|
||||||
@@ -4632,9 +4640,8 @@ export async function aiMergeTask(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b. Ensure rootDir is on the main branch before merging.
|
// 3b. Ensure rootDir is on the resolved merge target before merging.
|
||||||
// Without this, a merge could land on whatever branch was last checked out,
|
// Without this, a merge could land on whatever branch was last checked out.
|
||||||
// causing feature code to be committed to the wrong lineage.
|
|
||||||
try {
|
try {
|
||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
|
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
|
||||||
@@ -4642,32 +4649,16 @@ export async function aiMergeTask(
|
|||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
}).trim();
|
}).trim();
|
||||||
const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
|
if (currentBranch !== mergeTarget.branch) {
|
||||||
cwd: rootDir,
|
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mergeTarget.branch}' before merge (${mergeTarget.source})`);
|
||||||
encoding: "utf-8",
|
await execAsync(`git checkout "${mergeTarget.branch}"`, {
|
||||||
stdio: "pipe",
|
|
||||||
}).trim().replace(/^origin\//, "");
|
|
||||||
if (currentBranch !== mainBranch) {
|
|
||||||
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mainBranch}' before merge`);
|
|
||||||
await execAsync(`git checkout "${mainBranch}"`, {
|
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
});
|
});
|
||||||
// Audit trail: record git checkout (FN-1404)
|
await audit.git({ type: "branch:checkout", target: mergeTarget.branch });
|
||||||
await audit.git({ type: "branch:checkout", target: mainBranch });
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
rethrowIfMergeAborted(error);
|
rethrowIfMergeAborted(error);
|
||||||
|
mergerLog.warn(`${taskId}: unable to verify/checkout merge target '${mergeTarget.branch}' — proceeding on current HEAD`);
|
||||||
// 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`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3c. Pre-merge remote rebase.
|
// 3c. Pre-merge remote rebase.
|
||||||
@@ -5739,6 +5730,8 @@ export async function aiMergeTask(
|
|||||||
mergeCommitMessage: aiMergeSummary || commitLog,
|
mergeCommitMessage: aiMergeSummary || commitLog,
|
||||||
mergedAt: new Date().toISOString(),
|
mergedAt: new Date().toISOString(),
|
||||||
mergeConfirmed: true,
|
mergeConfirmed: true,
|
||||||
|
mergeTargetBranch: mergeTarget.branch,
|
||||||
|
mergeTargetSource: mergeTarget.source,
|
||||||
resolutionStrategy: result.resolutionStrategy,
|
resolutionStrategy: result.resolutionStrategy,
|
||||||
resolutionMethod: result.resolutionMethod,
|
resolutionMethod: result.resolutionMethod,
|
||||||
attemptsMade: result.attemptsMade,
|
attemptsMade: result.attemptsMade,
|
||||||
@@ -5756,7 +5749,7 @@ export async function aiMergeTask(
|
|||||||
if (recordedSha) {
|
if (recordedSha) {
|
||||||
summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
|
summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
|
||||||
} else if (mergeWasEmpty) {
|
} 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) {
|
} else if (isEmptyCommit) {
|
||||||
summaryParts.push("squash collapsed to empty (sha deferred)");
|
summaryParts.push("squash collapsed to empty (sha deferred)");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user