From 4397cafb946599cd38fa7ed37fe4436bcf5240af Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 22:16:22 -0700 Subject: [PATCH] fix: wire push-after-merge into the unified runAiMerge path with remote/branch dropdown settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushAfterMerge was only implemented in the soft-deprecated legacy aiMergeTask pipeline, so after master-plan U0 made runAiMerge the sole merge path the setting silently did nothing and origin fell permanently behind local main. - runAiMerge now runs a post-finalize push step: working-tree-independent ref-to-ref push fast path; on remote divergence a detached clean-room pull --rebase (with AI conflict resolution) pushes HEAD and CAS-advances the local integration ref (explicit non-FF opt-in, push path only), then runs merge-advance auto-sync and refreshes mergeDetails.commitSha. - Push failures stay non-fatal (task finalizes done) with push:origin run-audit events and PushToRemoteFailed task-log entries. - Merge settings: Push Remote free-text replaced by remote + target-branch dropdowns (Custom… escape, free-text fallback when no remotes), persisting to the same pushRemote setting string. New GET /api/git/remotes/:name/branches endpoint lists remote-tracking branches. Co-Authored-By: Claude Fable 5 --- .../fn-push-after-merge-unified-path.md | 7 + packages/dashboard/app/api/legacy.ts | 5 + .../app/components/SettingsModal.tsx | 11 + .../settings/sections/MergeSection.tsx | 100 ++++++- ...ergeSection.push-remote-dropdowns.test.tsx | 140 +++++++++ .../src/__tests__/routes-git.test.ts | 43 +++ .../src/routes/register-git-github.ts | 55 ++++ .../merger-ai-push-after-merge.test.ts | 238 +++++++++++++++ packages/engine/src/merger-ai.ts | 270 +++++++++++++++++- .../engine/src/merger-ref-update-advance.ts | 16 +- packages/engine/src/merger.ts | 37 ++- packages/i18n/locales/en/app.json | 6 +- packages/i18n/locales/es/app.json | 9 +- packages/i18n/locales/fr/app.json | 6 +- packages/i18n/locales/ko/app.json | 9 +- packages/i18n/locales/zh-CN/app.json | 9 +- packages/i18n/locales/zh-TW/app.json | 9 +- 17 files changed, 944 insertions(+), 26 deletions(-) create mode 100644 .changeset/fn-push-after-merge-unified-path.md create mode 100644 packages/dashboard/app/components/settings/sections/__tests__/MergeSection.push-remote-dropdowns.test.tsx create mode 100644 packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts diff --git a/.changeset/fn-push-after-merge-unified-path.md b/.changeset/fn-push-after-merge-unified-path.md new file mode 100644 index 0000000000..9252aafb48 --- /dev/null +++ b/.changeset/fn-push-after-merge-unified-path.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix push to remote after merge never running; pick the push remote and target branch from dropdowns in settings. +category: fix +dev: The `pushAfterMerge` setting only existed in the soft-deprecated legacy `aiMergeTask` pipeline; `runAiMerge` (the sole merge path since master-plan U0) now runs a post-finalize push step — ref-to-ref fast path, clean-room detached rebase with AI conflict resolution on remote divergence (non-FF local ref CAS advance + merge-advance auto-sync), `push:origin` run-audit events, non-fatal failures. New `GET /api/git/remotes/:name/branches` endpoint backs the settings dropdowns; the `pushRemote` setting string ("origin" / "origin main") is unchanged. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 4befc85161..9434bf784a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3462,6 +3462,11 @@ export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId), repoPath)); } +/** Fetch branch names known on a specific remote (from local remote-tracking refs). */ +export function fetchGitRemoteBranches(remote: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/branches`, projectId), repoPath)); +} + /** Fetch all local branches */ export function fetchGitBranches(projectId?: string, repoPath?: string): Promise { return api(withRepoPath(withProjectId("/git/branches", projectId), repoPath)); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 465294a5eb..a5dc0451aa 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1800,6 +1800,15 @@ export function SettingsModal({ // free-text entry. Best-effort — falls back to empty list (custom-only). useEffect(() => { if (activeSection !== "merge") return; + /* + FNXC:MergePush 2026-07-11-22:50: + The push-after-merge target is now picked from dropdowns (remote + branch on that + remote) instead of a free-text field, so the merge section also needs the remote + list. Best-effort — an empty list makes MergeSection fall back to free-text entry. + */ + fetchGitRemotesDetailed(projectId) + .then((remotes) => setGitRemotes(remotes)) + .catch(() => setGitRemotes([])); fetchGitBranches(projectId) .then((branches) => { const names = branches @@ -3583,6 +3592,8 @@ export function SettingsModal({ integrationBranchCustomMode={integrationBranchCustomMode} setIntegrationBranchCustomMode={setIntegrationBranchCustomMode} onOpenWorkflowSettings={onOpenWorkflowSettings} + gitRemoteOptions={gitRemotes.map((r) => r.name)} + projectId={projectId} /> ); case "agent-permissions": diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 5f9e4f7b5e..b7af9522f1 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -2,8 +2,31 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Settings } from "@fusion/core"; +import { fetchGitRemoteBranches } from "../../../api"; import { MovedSettingsStub } from "./MovedSettingsStub"; import type { SectionBaseProps } from "./context"; +/* +FNXC:MergePush 2026-07-11-23:00: +The push-after-merge target used to be one free-text field ("origin" or "origin main"), +which was easy to mistype and gave no discoverability of what could be pushed where. It +is now split into a remote dropdown (the repo's configured git remotes) and a target +branch dropdown (branches known on that remote, defaulting to the integration branch), +while still persisting to the single `pushRemote` setting string so the engine parser +and existing configs are unchanged. A Custom… escape hatch covers branches that don't +exist on the remote yet (pushing creates them), and the free-text input returns as a +fallback when no remotes are configured. +*/ +export function parsePushRemoteSetting(pushRemote: string | undefined): { remote: string; branch: string } { + const tokens = (pushRemote ?? "").trim().split(/\s+/).filter(Boolean); + return { remote: tokens[0] ?? "origin", branch: tokens.slice(1).join(" ") }; +} +export function composePushRemoteSetting(remote: string, branch: string): string | undefined { + const trimmedRemote = remote.trim() || "origin"; + const trimmedBranch = branch.trim(); + if (trimmedBranch) return `${trimmedRemote} ${trimmedBranch}`; + // Bare default remote with default branch = the setting's default — store unset. + return trimmedRemote === "origin" ? undefined : trimmedRemote; +} function resolveMaxAutoMergeRetriesForMergeForm(value: unknown): number { const configured = Number(value); return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3; @@ -33,9 +56,25 @@ export interface MergeSectionProps extends SectionBaseProps { integrationBranchCustomMode: boolean; setIntegrationBranchCustomMode: (value: boolean) => void; onOpenWorkflowSettings?: () => void; + /** Names of the repo's configured git remotes for the push-target dropdown. */ + gitRemoteOptions?: string[]; + projectId?: string; } -export function MergeSection({ scopeBanner, form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, }: MergeSectionProps) { +export function MergeSection({ scopeBanner, form, setForm, integrationBranchOptions, integrationBranchCustomMode, setIntegrationBranchCustomMode, onOpenWorkflowSettings, gitRemoteOptions = [], projectId, }: MergeSectionProps) { const { t } = useTranslation("app"); + const pushTarget = parsePushRemoteSetting(form.pushRemote); + const [pushBranchOptions, setPushBranchOptions] = useState([]); + const [pushBranchCustomMode, setPushBranchCustomMode] = useState(false); + // Load the branches known on the selected push remote whenever it changes. + // Best-effort: an empty list leaves the default + Custom… options usable. + useEffect(() => { + if (!form.pushAfterMerge || gitRemoteOptions.length === 0) return; + let cancelled = false; + fetchGitRemoteBranches(pushTarget.remote, projectId) + .then((branches) => { if (!cancelled) setPushBranchOptions(branches); }) + .catch(() => { if (!cancelled) setPushBranchOptions([]); }); + return () => { cancelled = true; }; + }, [form.pushAfterMerge, pushTarget.remote, projectId, gitRemoteOptions.length]); const [legacyStampCandidates, setLegacyStampCandidates] = useState([]); const [legacyStampLoading, setLegacyStampLoading] = useState(true); const [legacyStampApplying, setLegacyStampApplying] = useState(false); @@ -435,14 +474,69 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti - {form.pushAfterMerge && (
+ {form.pushAfterMerge && (gitRemoteOptions.length === 0 ? (
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))}/>
{t("settings.merge.moreDetails", "More details")} {t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}
-
)} +
) : (<> +
+ + +
+ {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.gitRemoteThatMergedResultsArePushedTo", "Git remote that merged results are pushed to. Default: \"origin\".")} +
+
+
+ + {(() => { + const currentBranch = pushTarget.branch; + const branchIsKnown = currentBranch.length > 0 && pushBranchOptions.includes(currentBranch); + if (pushBranchCustomMode || (currentBranch.length > 0 && !branchIsKnown)) { + return (
+ { + const trimmed = e.target.value.trim(); + setForm((f) => ({ ...f, pushRemote: composePushRemoteSetting(pushTarget.remote, trimmed) })); + }} data-testid="push-remote-branch-custom-input"/> + +
); + } + const CUSTOM = "__fusion-custom__"; + return (); + })()} +
+ {t("settings.merge.moreDetails", "More details")} + {t("settings.merge.pushTargetBranchHelp", "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it).")} +
+
+ ))} ); } export default MergeSection; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.push-remote-dropdowns.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.push-remote-dropdowns.test.tsx new file mode 100644 index 0000000000..17cbb78963 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.push-remote-dropdowns.test.tsx @@ -0,0 +1,140 @@ +/* +FNXC:MergePush 2026-07-11-23:35: +The push-after-merge target moved from one free-text field to a remote dropdown + target +branch dropdown (persisting to the same `pushRemote` setting string). These tests pin the +parse/compose round-trip, the dropdown rendering, the remote→branch reload, the Custom… +escape hatch, and the free-text fallback when the repo has no configured remotes. +*/ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MergeSection, parsePushRemoteSetting, composePushRemoteSetting } from "../MergeSection"; +import type { MergeSectionProps } from "../MergeSection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), +})); + +const mockFetchGitRemoteBranches = vi.fn(); +vi.mock("../../../../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchGitRemoteBranches: (...args: unknown[]) => mockFetchGitRemoteBranches(...args), + }; +}); + +function jsonResponse(body: unknown, ok = true): Response { + return { + ok, + json: async () => body, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + } as Response; +} + +function makeProps( + formOverrides: Partial = {}, + propOverrides: Partial = {}, +): MergeSectionProps { + return { + scopeBanner: null, + form: { + autoMerge: true, + planApprovalMode: "workflow", + merger: { mode: "ai" }, + testMode: false, + mergeStrategy: "direct", + pushAfterMerge: true, + ...formOverrides, + } as MergeSectionProps["form"], + setForm: vi.fn(), + integrationBranchOptions: ["main"], + integrationBranchCustomMode: false, + setIntegrationBranchCustomMode: vi.fn(), + gitRemoteOptions: ["origin", "upstream"], + projectId: "proj-1", + ...propOverrides, + }; +} + +function lastFormUpdate(props: MergeSectionProps): MergeSectionProps["form"] { + const updater = vi.mocked(props.setForm).mock.calls.at(-1)?.[0] as (state: MergeSectionProps["form"]) => MergeSectionProps["form"]; + return updater(props.form); +} + +describe("parsePushRemoteSetting / composePushRemoteSetting", () => { + it("round-trips the supported setting shapes", () => { + expect(parsePushRemoteSetting(undefined)).toEqual({ remote: "origin", branch: "" }); + expect(parsePushRemoteSetting("origin")).toEqual({ remote: "origin", branch: "" }); + expect(parsePushRemoteSetting("upstream main")).toEqual({ remote: "upstream", branch: "main" }); + expect(parsePushRemoteSetting(" upstream main ")).toEqual({ remote: "upstream", branch: "main" }); + + expect(composePushRemoteSetting("origin", "")).toBeUndefined(); + expect(composePushRemoteSetting("origin", "main")).toBe("origin main"); + expect(composePushRemoteSetting("upstream", "")).toBe("upstream"); + expect(composePushRemoteSetting("upstream", "release")).toBe("upstream release"); + }); +}); + +describe("MergeSection push remote/branch dropdowns", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ candidates: [], count: 0 }))); + mockFetchGitRemoteBranches.mockResolvedValue(["main", "develop"]); + }); + + it("renders remote + branch dropdowns and loads the selected remote's branches", async () => { + render(); + + const remoteSelect = screen.getByTestId("push-remote-select") as HTMLSelectElement; + expect(remoteSelect.value).toBe("origin"); + expect(screen.getByRole("option", { name: "upstream" })).toBeInTheDocument(); + + await waitFor(() => expect(mockFetchGitRemoteBranches).toHaveBeenCalledWith("origin", "proj-1")); + await waitFor(() => expect(screen.getByRole("option", { name: "develop" })).toBeInTheDocument()); + const branchSelect = screen.getByTestId("push-remote-branch-select") as HTMLSelectElement; + expect(branchSelect.value).toBe(""); + expect(screen.getByRole("option", { name: "(same as integration branch — default)" })).toBeInTheDocument(); + }); + + it("composes 'remote branch' into the pushRemote setting when a branch is picked", async () => { + const props = makeProps({ pushRemote: undefined }); + render(); + await waitFor(() => expect(screen.getByRole("option", { name: "develop" })).toBeInTheDocument()); + + fireEvent.change(screen.getByTestId("push-remote-branch-select"), { target: { value: "develop" } }); + expect(lastFormUpdate(props).pushRemote).toBe("origin develop"); + }); + + it("switching the remote resets the target branch and stores the bare remote", async () => { + const props = makeProps({ pushRemote: "origin develop" }); + render(); + + fireEvent.change(screen.getByTestId("push-remote-select"), { target: { value: "upstream" } }); + expect(lastFormUpdate(props).pushRemote).toBe("upstream"); + }); + + it("shows a persisted branch that is unknown on the remote as custom text input", async () => { + render(); + + const customInput = await screen.findByTestId("push-remote-branch-custom-input"); + expect(customInput).toHaveValue("not-fetched-yet"); + expect(screen.getByTestId("push-remote-branch-use-dropdown")).toBeInTheDocument(); + }); + + it("falls back to the free-text Push Remote input when no remotes are configured", () => { + render(); + + expect(screen.queryByTestId("push-remote-select")).not.toBeInTheDocument(); + const input = screen.getByLabelText("Push Remote") as HTMLInputElement; + expect(input.value).toBe("origin main"); + expect(mockFetchGitRemoteBranches).not.toHaveBeenCalled(); + }); + + it("renders nothing push-related when push-after-merge is disabled", () => { + render(); + + expect(screen.queryByTestId("push-remote-select")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument(); + expect(mockFetchGitRemoteBranches).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-git.test.ts b/packages/dashboard/src/__tests__/routes-git.test.ts index a7faa3f3bf..be93410547 100644 --- a/packages/dashboard/src/__tests__/routes-git.test.ts +++ b/packages/dashboard/src/__tests__/routes-git.test.ts @@ -766,6 +766,49 @@ describe("Git Management endpoints", () => { }); }); + /* + FNXC:MergePush 2026-07-11-23:45: + Backs the Merge settings push-target branch dropdown: branch names known on a remote, + read from local remote-tracking refs (offline-fast), excluding the HEAD symbolic ref. + */ + describe("GET /git/remotes/:name/branches", () => { + it("returns the branches known on the remote", async () => { + const res = await GET(buildApp(), "/api/git/remotes/origin/branches"); + + expect(res.status).toBe(200); + expect(res.body).toContain("main"); + expect(res.body).not.toContain("HEAD"); + }); + + it("returns 400 for an invalid remote name", async () => { + const res = await GET(buildApp(), "/api/git/remotes/invalid;rm%20-rf%20/branches"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Invalid remote name"); + }); + + it("returns an empty array for a non-existent remote", async () => { + const res = await GET(buildApp(), "/api/git/remotes/nonexistent-remote-xyz/branches"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns 400 when not a git repository", async () => { + const nonGitStore = createMockStore({ + getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"), + }); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(nonGitStore)); + + const res = await GET(app, "/api/git/remotes/origin/branches"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Not a git repository"); + }); + }); + describe("GET /git/branches", () => { it("returns branches array", async () => { const res = await GET(buildApp(), "/api/git/branches"); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 8a9d5ef82b..eec6873cd4 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1077,6 +1077,36 @@ export async function getGitBranches(cwd?: string): Promise { } } +/* +FNXC:MergePush 2026-07-11-22:40: +The Merge settings push-target dropdown needs the branches that exist ON a given remote. +Read local remote-tracking refs (refs/remotes//) instead of `git ls-remote` so the +listing is instant and offline-safe; a branch created remotely since the last fetch is +covered by the dropdown's Custom… escape hatch. +*/ +export async function getGitRemoteBranches(remoteName: string, cwd?: string): Promise { + try { + const output = (await runGitCommand( + ["for-each-ref", "--format=%(refname:short)", `refs/remotes/${remoteName}/`], + cwd, + 10000, + )).trim(); + const prefix = `${remoteName}/`; + const branches: string[] = []; + for (const line of output.split("\n")) { + const short = line.trim(); + if (!short.startsWith(prefix)) continue; + const branch = short.slice(prefix.length); + // `/HEAD` is a symbolic pointer, not a pushable branch. + if (!branch || branch === "HEAD") continue; + branches.push(branch); + } + return branches; + } catch { + return []; + } +} + export interface GitWorktree { path: string; branch?: string; @@ -3016,6 +3046,31 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { } }); + /** + * GET /api/git/remotes/:name/branches + * Returns branch names known on a specific remote (from local remote-tracking refs). + * Response: string[] (e.g. ["main", "develop"]) — excludes the HEAD symbolic ref. + */ + router.get("/git/remotes/:name/branches", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const rootDir = resolveGitDir(req, scopedStore.getRootDir()); + if (!(await isGitRepo(rootDir))) { + throw badRequest("Not a git repository"); + } + const { name } = req.params; + if (!isValidBranchName(name)) { + throw badRequest("Invalid remote name"); + } + res.json(await getGitRemoteBranches(name, rootDir)); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + /** * GET /api/git/remotes/:name/commits * Returns recent commits for a specific remote tracking ref. diff --git a/packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts b/packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts new file mode 100644 index 0000000000..9d3bbaea3c --- /dev/null +++ b/packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts @@ -0,0 +1,238 @@ +/* +FNXC:MergePush 2026-07-11-23:20: +Regression + invariant coverage for push-after-merge on the UNIFIED merge path. + +Original symptom: with `pushAfterMerge: true` (direct merge strategy), tasks merged via +`runAiMerge` — the sole production merge path since master-plan U0 — landed on the local +integration ref but were NEVER pushed; the setting was only implemented in the +soft-deprecated legacy `aiMergeTask` pipeline, so origin fell permanently behind local main. + +Exact reproduction: init a repo with a bare `origin`, enable `pushAfterMerge`, run +`runAiMerge` end-to-end with mock agents. + +Assertion it is gone: origin/main equals the landed local main after the merge, across the +enumerated surfaces — fast path (remote behind), divergence path (remote moved ahead → +clean-room rebase + non-FF local ref advance), explicit "remote branch" push targets, +setting disabled (no push), and push failure (non-fatal: task still finalizes done). +*/ +import { describe, it, expect, vi, afterAll } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; + +const createResolvedAgentSessionMock = vi.hoisted(() => vi.fn()); +vi.mock("../agent-session-helpers.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createResolvedAgentSession: createResolvedAgentSessionMock, + }; +}); +vi.mock("../pi.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise | void }, prompt: string) => { + await session.prompt(prompt); + }), + }; +}); + +import { runAiMerge } from "../merger-ai.js"; + +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; +const tracked = new Set(); +afterAll(() => { + for (const d of tracked) { + try { rmSync(d, RM); } catch { /* best effort */ } + } +}); + +function git(cwd: string, args: string): string { + return execSync(`git ${args}`, { cwd, encoding: "utf-8" }).trim(); +} + +/** A repo on `main` with a bare `origin` remote (main pushed) + a task branch. */ +function initRepoWithRemote(opts: { branch: string } = { branch: "fusion/fn-1" }): { dir: string; originDir: string } { + const root = mkdtempSync(join(tmpdir(), "fusion-ai-merge-push-test-")); + tracked.add(root); + const originDir = join(root, "origin.git"); + const dir = join(root, "work"); + execSync(`git init -q --bare "${originDir}"`, { encoding: "utf-8" }); + execSync(`git init -q -b main "${dir}"`, { encoding: "utf-8" }); + git(dir, "config user.email t@t.t"); + git(dir, "config user.name t"); + writeFileSync(join(dir, "base.txt"), "base\n"); + git(dir, "add -A"); + git(dir, "commit -q -m base"); + git(dir, `remote add origin "${originDir}"`); + git(dir, "push -q origin main"); + + git(dir, `checkout -q -b ${opts.branch}`); + writeFileSync(join(dir, "feature.txt"), "feature work\n"); + git(dir, "add -A"); + git(dir, "commit -q -m 'feat: work'"); + git(dir, "checkout -q main"); + return { dir, originDir }; +} + +/** Commit to origin/main via a second clone (simulates the remote moving ahead). */ +function advanceOrigin(originDir: string, fileName: string): void { + const clone = mkdtempSync(join(tmpdir(), "fusion-ai-merge-push-other-")); + tracked.add(clone); + execSync(`git clone -q "${originDir}" "${clone}"`, { encoding: "utf-8" }); + git(clone, "config user.email o@o.o"); + git(clone, "config user.name o"); + writeFileSync(join(clone, fileName), "remote side\n"); + git(clone, "add -A"); + git(clone, `commit -q -m 'remote: ${fileName}'`); + git(clone, "push -q origin main"); +} + +function makeStore(settingsOverrides: Record = {}) { + const task: Record = { + id: "FN-1", + column: "in-review", + status: null, + branch: "fusion/fn-1", + worktree: null, + title: "do the thing", + steps: [], + }; + const logs: Array<{ message: string; action?: string }> = []; + const store = { + getTask: vi.fn(async () => task), + getSettings: vi.fn(async () => ({ + merger: { mode: "ai", maxReviewPasses: 1 }, + pushAfterMerge: true, + ...settingsOverrides, + })), + updateTask: vi.fn(async (_id: string, patch: Record) => { Object.assign(task, patch); return task; }), + moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }), + emit: vi.fn(), + logEntry: vi.fn(async (_id: string, message: string, action?: string) => { logs.push({ message, action }); }), + appendAgentLog: vi.fn(async (_id: string, message: string) => { logs.push({ message }); }), + getBranchGroup: vi.fn(() => null), + recordRunAuditEvent: vi.fn(), + }; + return { store: store as never, storeMocks: store, task, logs }; +} + +function realMergeAgent(branch: string) { + return vi.fn(async (cwd: string) => { + execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" }); + execSync("git add -A", { cwd, stdio: "pipe" }); + execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" }); + }); +} + +const approveReviewer = () => vi.fn(async () => "REVIEW_VERDICT: approve"); + +describe("runAiMerge push-after-merge", () => { + it("pushes the landed integration branch to origin (fast path, remote behind)", async () => { + const { dir, originDir } = initRepoWithRemote(); + const { store, storeMocks } = makeStore(); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.merged).toBe(true); + expect(result.pushedToRemote).toBe(true); + expect(result.pushError).toBeUndefined(); + // The original symptom: origin/main used to stay at base forever. + expect(git(originDir, "rev-parse main")).toBe(git(dir, "rev-parse main")); + expect(storeMocks.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "push:origin", + metadata: expect.objectContaining({ outcome: "success" }), + })); + }); + + it("rebases in a clean room and pushes when the remote has diverged (non-FF path)", async () => { + const { dir, originDir } = initRepoWithRemote(); + // Remote moves ahead AFTER our clone: the fast-path push must reject non-FF. + advanceOrigin(originDir, "remote.txt"); + const { store, task } = makeStore(); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.merged).toBe(true); + expect(result.pushedToRemote).toBe(true); + const originMain = git(originDir, "rev-parse main"); + const localMain = git(dir, "rev-parse main"); + // Local integration ref advanced (non-FF opt-in) to the rebased sha that origin now has. + expect(localMain).toBe(originMain); + // The rebased tip contains BOTH the remote commit and the rebased squash. + const subjects = git(dir, "log --pretty=%s main"); + expect(subjects).toContain("remote: remote.txt"); + expect(subjects).toMatch(/FN-1: /); + // mergeDetails.commitSha was refreshed to the rebased (reachable) sha. + expect((task.mergeDetails as { commitSha?: string }).commitSha).toBe(localMain); + }); + + it("honors an explicit 'remote branch' push target", async () => { + const { dir, originDir } = initRepoWithRemote(); + const { store } = makeStore({ pushRemote: "origin release" }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.pushedToRemote).toBe(true); + // The push created the `release` branch on the remote at the landed sha. + expect(git(originDir, "rev-parse release")).toBe(git(dir, "rev-parse main")); + }); + + it("does not push when pushAfterMerge is disabled", async () => { + const { dir, originDir } = initRepoWithRemote(); + const baseSha = git(originDir, "rev-parse main"); + const { store } = makeStore({ pushAfterMerge: false }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.merged).toBe(true); + expect(result.pushedToRemote).toBeUndefined(); + expect(git(originDir, "rev-parse main")).toBe(baseSha); + }); + + it("does not push when mergeStrategy is pull-request even if pushAfterMerge is on", async () => { + const { dir, originDir } = initRepoWithRemote(); + const baseSha = git(originDir, "rev-parse main"); + const { store } = makeStore({ mergeStrategy: "pull-request" }); + + // Direct runAiMerge call (the PR flow gates elsewhere; this asserts the + // step-level guard mirrors the legacy `mergeStrategy !== "pull-request"` gate). + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.pushedToRemote).toBeUndefined(); + expect(git(originDir, "rev-parse main")).toBe(baseSha); + }); + + it("finalizes the task even when the push fails (non-fatal contract)", async () => { + const { dir } = initRepoWithRemote(); + const { store, task, logs } = makeStore({ pushRemote: "nonexistent-remote" }); + + const result = await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: approveReviewer(), + }); + + expect(result.merged).toBe(true); + expect(task.column).toBe("done"); + expect(result.pushedToRemote).toBe(false); + expect(result.pushError).toBeTruthy(); + expect(logs.some((l) => l.action === "PushToRemoteFailed")).toBe(true); + }); +}); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 31d1adbd86..a035f0e5ed 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -46,6 +46,7 @@ import { evaluateNoCommitsNoOpFinalize, getPrimaryPrInfo, getTaskMergeBlocker, + normalizeMergeAdvanceAutoSyncMode, resolvePersistAgentThinkingLog, resolveTaskMergeTarget, resolveValidatorSettingsModel, @@ -68,7 +69,15 @@ import { checkSessionError } from "./usage-limit-detector.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js"; import { createLogger } from "./logger.js"; -import { captureSingleCommitLandedMetadata, syncGroupPrOnLanding, type MergerOptions } from "./merger.js"; +import { + captureSingleCommitLandedMetadata, + isNonFastForwardPushError, + parsePushRemoteTarget, + pushToRemoteAfterMerge, + runMergeAdvanceAutoSync, + syncGroupPrOnLanding, + type MergerOptions, +} from "./merger.js"; import { resolveBranchGroupMergeRouting, type BranchGroupMergeRouting, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { DEFAULT_COMMIT_AUTHOR_EMAIL, DEFAULT_COMMIT_AUTHOR_NAME } from "./worktree-hooks.js"; import { installWorktreeDependencies } from "./merge-dependency-sync.js"; @@ -1127,10 +1136,115 @@ export async function runAiMerge( }; } await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`); - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }, mergeTarget, groupRouting, options.syncGroupPr); + const noOpFinalized = await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }, mergeTarget, groupRouting, options.syncGroupPr); + await runPushAfterMergeStep({ store, projectRootDir, taskId, settings, integrationBranch, audit, log, options, result: noOpFinalized }); + return noOpFinalized; } - return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }, mergeTarget, groupRouting, options.syncGroupPr); + const finalized = await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }, mergeTarget, groupRouting, options.syncGroupPr); + await runPushAfterMergeStep({ store, projectRootDir, taskId, settings, integrationBranch, audit, log, options, result: finalized }); + return finalized; +} + +/* +FNXC:MergePush 2026-07-11-22:25: +Post-finalization push step for the sole production merge path. Runs AFTER the task is +finalized (mirrors the legacy contract: "task marked done anyway; local main may diverge +from origin" on failure) so a push problem can never park or roll back a landed merge. +Also runs after an empty/no-op finalize: the integration ref may still be ahead of the +remote from earlier merges whose pushes failed, and pushing an up-to-date remote is a +free no-op — this makes the setting self-healing. Every attempt emits a `push:origin` +run-audit event; failures additionally get a durable task-log entry. +*/ +async function runPushAfterMergeStep(input: { + store: TaskStore; + projectRootDir: string; + taskId: string; + settings: Settings; + integrationBranch: string; + audit: RunAuditor; + log: (message: string) => Promise; + options: MergerOptions; + result: MergeResult; +}): Promise { + const { store, projectRootDir, taskId, settings, integrationBranch, audit, log, options, result } = input; + if (settings.pushAfterMerge !== true || settings.mergeStrategy === "pull-request") return; + try { + const pushOutcome = await pushAfterMergeToRemote({ + store, + projectRootDir, + taskId, + settings, + integrationBranch, + audit, + log, + signal: options.signal, + onAgentText: options.onAgentText, + onSession: options.onSession, + }); + result.pushedToRemote = pushOutcome.pushed; + if (pushOutcome.error) result.pushError = pushOutcome.error; + await audit.git({ + type: "push:origin", + target: taskId, + metadata: { + integrationBranch, + remote: pushOutcome.remote ?? settings.pushRemote ?? "origin", + targetBranch: pushOutcome.targetBranch, + outcome: pushOutcome.pushed ? "success" : "failed", + refAdvanced: pushOutcome.refAdvanced, + ...(pushOutcome.error ? { stderrPreview: pushOutcome.error.slice(0, 500) } : {}), + }, + }).catch(() => undefined); + if (pushOutcome.pushed) { + await log(`Push after merge: pushed ${integrationBranch} to ${pushOutcome.remote}/${pushOutcome.targetBranch}`); + // A divergence rebase rewrote the landed squash — refresh the recorded + // commitSha/stats so mergeDetails don't reference an orphaned commit + // (mirrors the legacy post-push refresh). + if (pushOutcome.refAdvanced && pushOutcome.rebasedSha) { + try { + const latest = await store.getTask(taskId).catch(() => null); + const details = latest?.mergeDetails; + if (details?.commitSha && details.commitSha !== pushOutcome.rebasedSha) { + const { filesChanged, insertions, deletions } = await captureSingleCommitLandedMetadata(projectRootDir, pushOutcome.rebasedSha); + await store.updateTask(taskId, { + mergeDetails: { ...details, commitSha: pushOutcome.rebasedSha, filesChanged, insertions, deletions }, + }); + } + } catch (refreshErr: unknown) { + aiMergeLog.warn(`${taskId}: post-push mergeDetails refresh failed: ${getErrorMessage(refreshErr)}`); + } + } + } else { + aiMergeLog.warn(`${taskId}: push to remote failed: ${pushOutcome.error}`); + await store.logEntry( + taskId, + `Push to remote failed after merge — task finalized anyway; local ${integrationBranch} may diverge from ${pushOutcome.remote ?? "origin"}: ${pushOutcome.error}`, + "PushToRemoteFailed", + ).catch(() => undefined); + } + } catch (err: unknown) { + if (err instanceof Error && err.name === "MergeAbortedError") { + // The task already finalized — an abort mid-push must not re-surface as a + // failed/aborted merge. Skip quietly; the next merge's push reconciles. + aiMergeLog.warn(`${taskId}: push after merge aborted by shutdown signal — skipping (merge already finalized)`); + return; + } + const message = getErrorMessage(err); + result.pushedToRemote = false; + result.pushError = message; + aiMergeLog.error(`${taskId}: push to remote threw: ${message}`); + await audit.git({ + type: "push:origin", + target: taskId, + metadata: { integrationBranch, remote: settings.pushRemote ?? "origin", outcome: "failed", stderrPreview: message.slice(0, 500) }, + }).catch(() => undefined); + await store.logEntry( + taskId, + `Push to remote threw after merge — task finalized anyway; local ${integrationBranch} may diverge from origin: ${message}`, + "PushToRemoteFailed", + ).catch(() => undefined); + } } // --------------------------------------------------------------------------- @@ -1695,6 +1809,156 @@ async function mergeAndReview(input: { } } +/* +FNXC:MergePush 2026-07-11-22:25: +Push-after-merge for the unified AI merge path. The `pushAfterMerge` setting was only ever +implemented in the soft-deprecated legacy `aiMergeTask` pipeline (merger.ts step 8b), so after +master-plan U0 made `runAiMerge` the sole merge path the setting silently did nothing — merges +landed on the local integration ref and the remote fell permanently behind. This helper restores +the behavior without ever touching the user's working tree: + +1. Fast path — a pure ref-to-ref `git push refs/heads/:refs/heads/` from the + project root. Push is working-tree-independent, so a dirty checkout or a checkout on a + different branch can never break the common case (remote is simply behind or up to date). +2. Divergence path — a rejected non-fast-forward push means the remote gained commits the local + ref lacks. Mirror the clean-room philosophy of the merge itself: build a throwaway DETACHED + worktree at the local integration tip and run the legacy `pushToRemoteAfterMerge` pipeline + inside it (`git pull --rebase` + AI conflict resolution + bounded non-FF retries), pushing + `HEAD:refs/heads/`. On success, CAS-advance the local integration ref to the rebased + sha (explicit non-FF opt-in — rebase rewrites by construction) and run the standard + merge-advance auto-sync so checkouts on that branch catch up. + +Failures are ALWAYS non-fatal: the merge already landed locally, so the task finalization must +never be blocked or rolled back by a push problem. Outcome is surfaced via the `push:origin` +run-audit event, a task-log entry, and MergeResult.pushedToRemote/pushError. +*/ +export async function pushAfterMergeToRemote(input: { + store: TaskStore; + projectRootDir: string; + taskId: string; + settings: Settings; + integrationBranch: string; + audit: RunAuditor; + log: (message: string) => Promise; + signal?: AbortSignal; + onAgentText?: (delta: string) => void; + onSession?: (session: { dispose: () => void }) => void; +}): Promise<{ pushed: boolean; remote?: string; targetBranch?: string; refAdvanced?: boolean; rebasedSha?: string; error?: string }> { + const { store, projectRootDir, taskId, settings, integrationBranch, audit, log, signal } = input; + + let remote: string; + let targetBranch: string; + try { + const target = parsePushRemoteTarget(projectRootDir, settings.pushRemote, integrationBranch); + remote = target.remote; + targetBranch = target.branch; + } catch (err: unknown) { + return { pushed: false, error: `invalid push remote configuration: ${getErrorMessage(err)}` }; + } + + const localRef = `refs/heads/${integrationBranch}`; + const localSha = await git(["rev-parse", "--verify", localRef], projectRootDir).catch(() => ""); + if (!localSha) { + return { pushed: false, remote, targetBranch, error: `local integration ref ${localRef} not found` }; + } + + // 1. Fast path: ref-to-ref push, no working tree involved. + throwIfAborted(signal, taskId); + let fastPathError: string; + try { + await git(["push", remote, `${localRef}:refs/heads/${targetBranch}`], projectRootDir, { timeout: 120_000 }); + return { pushed: true, remote, targetBranch }; + } catch (err: unknown) { + fastPathError = getErrorMessage(err); + } + if (!isNonFastForwardPushError(fastPathError)) { + return { pushed: false, remote, targetBranch, error: fastPathError }; + } + + // 2. Divergence path: remote moved ahead — rebase in a detached clean room. + await log(`Push after merge: ${remote}/${targetBranch} has diverged — rebasing in a clean room before pushing`); + let pushRoot: string | undefined; + let worktreeAdded = false; + const registeredPaths = new Set(); + try { + pushRoot = await mkdtemp(join(resolveAiMergeRoot(projectRootDir, settings), `fusion-ai-merge-push-${taskId.toLowerCase()}-`)); + for (const p of [pushRoot]) { + activeSessionRegistry.registerPath(p, { taskId, kind: "ai-merge", ownerKey: `ai-merge-push:${taskId}` }); + registeredPaths.add(p); + } + await git(["worktree", "add", "--detach", pushRoot, localSha], projectRootDir); + worktreeAdded = true; + let canonicalPushRoot = pushRoot; + try { + canonicalPushRoot = realpathSync(pushRoot); + } catch { + canonicalPushRoot = pushRoot; + } + if (!registeredPaths.has(canonicalPushRoot)) { + activeSessionRegistry.registerPath(canonicalPushRoot, { taskId, kind: "ai-merge", ownerKey: `ai-merge-push:${taskId}` }); + registeredPaths.add(canonicalPushRoot); + } + + const pushResult = await pushToRemoteAfterMerge(store, canonicalPushRoot, taskId, settings, { + integrationBranch: targetBranch, + pushHeadRefspec: true, + signal, + onAgentText: input.onAgentText, + onSession: input.onSession, + }); + if (!pushResult.pushed) { + return { pushed: false, remote, targetBranch, error: pushResult.error }; + } + + // The clean-room HEAD is what the remote now has. Advance the local + // integration ref to match (CAS against the pre-push tip; a concurrent + // local advance loses the race and the NEXT merge's push reconciles). + const rebasedSha = await git(["rev-parse", "HEAD"], canonicalPushRoot).catch(() => ""); + if (!rebasedSha || rebasedSha === localSha) { + return { pushed: true, remote, targetBranch }; + } + const adv = await advanceIntegrationBranchRef({ + rootDir: canonicalPushRoot, + projectRootDir, + integrationBranch, + newSha: rebasedSha, + expectedCurrentSha: localSha, + taskId, + audit, + allowNonFastForward: true, + }); + if (!adv.advanced) { + await log(`Push after merge: pushed rebased result to ${remote}/${targetBranch}, but ${integrationBranch} moved concurrently — local ref left as-is (${adv.reason}); the next merge's push will reconcile`); + return { pushed: true, remote, targetBranch, refAdvanced: false, rebasedSha }; + } + const autoSyncMode = normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync); + if (autoSyncMode !== "off") { + try { + await runMergeAdvanceAutoSync({ + store, + audit, + taskId, + projectRootDir, + integrationBranch, + previousSha: localSha, + newSha: rebasedSha, + mode: autoSyncMode, + }); + } catch (syncErr: unknown) { + aiMergeLog.warn(`${taskId}: merge-advance auto-sync after push rebase threw — continuing: ${getErrorMessage(syncErr)}`); + } + } + return { pushed: true, remote, targetBranch, refAdvanced: true, rebasedSha }; + } finally { + for (const registeredPath of registeredPaths) { + activeSessionRegistry.unregisterPath(registeredPath); + } + if (pushRoot) { + await cleanupAiMergeWorktree({ taskId, mergeRoot: pushRoot, projectRootDir, worktreeAdded, audit, log }); + } + } +} + async function finalizeMerged( store: TaskStore, projectRootDir: string, diff --git a/packages/engine/src/merger-ref-update-advance.ts b/packages/engine/src/merger-ref-update-advance.ts index 691b001284..d913ce05de 100644 --- a/packages/engine/src/merger-ref-update-advance.ts +++ b/packages/engine/src/merger-ref-update-advance.ts @@ -51,6 +51,16 @@ export async function advanceIntegrationBranchRef(args: { expectedCurrentSha: string; taskId: string; audit: RunAuditor; + /* + FNXC:MergePush 2026-07-11-22:20: + Explicit opt-in for the push-after-merge divergence path ONLY. A `git pull --rebase` + against a diverged remote rewrites the local-only commits on top of the remote tip, so + the resulting sha can never descend from the old local tip — a non-fast-forward ref move + is inherent to rebase, not an orphaning bug (the rewritten commits carry the same diffs, + and the old tip stays reachable via the reflog). The CAS old-value check still guards + against concurrent movement. Merge landings must NEVER set this. + */ + allowNonFastForward?: boolean; }): Promise< | { advanced: true; previousSha: string; newSha: string } | { @@ -156,8 +166,10 @@ export async function advanceIntegrationBranchRef(args: { // tip. CAS alone (old-value match) lets a sibling commit overwrite the ref // and orphan the prior tip — the exact shape that left an FN-trailered // squash reachable only from a feature branch when a subsequent merger - // built its squash off a stale base. Reject non-FF advances. - if (newSha !== expectedCurrentSha) { + // built its squash off a stale base. Reject non-FF advances unless the + // caller explicitly opted in (push-after-merge divergence rebase — see the + // allowNonFastForward doc above). + if (newSha !== expectedCurrentSha && args.allowNonFastForward !== true) { try { await testHooks.runGit( ["merge-base", "--is-ancestor", expectedCurrentSha, newSha], diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 5c41172710..f6990c08f4 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -180,7 +180,13 @@ async function resolveMergerMcpServers(store?: TaskStore, agentId?: string | nul * Best-effort: any per-worktree failure is recorded as an audit event and the * loop continues — the merge has already landed and the auto-sync is convenience. */ -async function runMergeAdvanceAutoSync(input: { +/* +FNXC:MergePush 2026-07-11-22:10: +Exported for the unified AI merge path: after a push-time divergence rebase CAS-advances +refs/heads/, the same other-worktree catch-up (stash-and-ff / ff-only) +must run so the user's checkout doesn't show the rebased commits inverted as staged changes. +*/ +export async function runMergeAdvanceAutoSync(input: { store: TaskStore; audit: RunAuditor; taskId: string; @@ -6981,7 +6987,13 @@ function getCommandErrorMessage(error: unknown): string { return String(error); } -function isNonFastForwardPushError(message: string): boolean { +/* +FNXC:MergePush 2026-07-11-22:10: +Exported for the unified AI merge path (merger-ai.ts pushAfterMergeToRemote): the production +runAiMerge pipeline needs the same rejected-push classification the legacy step-8b path used, +so divergence (remote moved) can be distinguished from hard failures (auth, missing remote). +*/ +export function isNonFastForwardPushError(message: string): boolean { const normalized = message.toLowerCase(); return normalized.includes("non-fast-forward") || normalized.includes("[rejected]") @@ -7001,7 +7013,13 @@ function isRebaseInProgress(rootDir: string): boolean { } } -function parsePushRemoteTarget(rootDir: string, pushRemote?: string, fallbackBranch?: string): { remote: string; branch: string } { +/* +FNXC:MergePush 2026-07-11-22:10: +Exported for the unified AI merge path (merger-ai.ts pushAfterMergeToRemote) so the +`pushRemote` setting keeps one parser: "origin" (target branch defaults to the integration +branch) or "origin main" (explicit remote + target branch). +*/ +export function parsePushRemoteTarget(rootDir: string, pushRemote?: string, fallbackBranch?: string): { remote: string; branch: string } { const rawTarget = pushRemote?.trim() || "origin"; const [remoteToken, ...branchTokens] = rawTarget.split(/\s+/).filter(Boolean); const remote = remoteToken || "origin"; @@ -7306,6 +7324,15 @@ export async function pushToRemoteAfterMerge( assignedAgentRuntimeConfig?: Record; onSession?: (session: { dispose: () => void }) => void; integrationBranch?: string; + /* + FNXC:MergePush 2026-07-11-22:10: + When true, push `HEAD:refs/heads/` instead of the local branch ref. The unified + AI merge path calls this from a DETACHED clean-room worktree (never the user's checkout), + where `git pull --rebase` rewrites the detached HEAD — the local refs/heads/ is + only advanced afterwards via compare-and-swap by the caller. Without this, the push would + resend the stale local ref after a divergence rebase and reject non-fast-forward forever. + */ + pushHeadRefspec?: boolean; }, ): Promise<{ pushed: boolean; error?: string }> { let target: { remote: string; branch: string }; @@ -7333,7 +7360,9 @@ export async function pushToRemoteAfterMerge( return { pushed: false, error: message }; } - const pushCommand = `git push ${quoteArg(remote)} ${quoteArg(branch)}`; + const pushCommand = options?.pushHeadRefspec + ? `git push ${quoteArg(remote)} ${quoteArg(`HEAD:refs/heads/${branch}`)}` + : `git push ${quoteArg(remote)} ${quoteArg(branch)}`; try { throwIfAborted(options?.signal, taskId); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 3321e7cdae..ec654ec410 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6228,7 +6228,11 @@ "gitLabPersonalAccessToken": "Personal access token (default)", "gitLabAuthTokenHint": "Read-only GitLab operations need read_api or api. Future write actions such as comments and auto-close need api. Project and group tokens are limited to their associated resource and role membership. No default — unset.", "includeTaskIdInCommitDefault": "). Default: enabled.", - "trailerEmail": " trailer. Default: noreply@runfusion.ai." + "trailerEmail": " trailer. Default: noreply@runfusion.ai.", + "gitRemoteThatMergedResultsArePushedTo": "Git remote that merged results are pushed to. Default: \"origin\".", + "pushTargetBranch": "Push target branch", + "sameAsIntegrationBranchDefault": "(same as integration branch — default)", + "pushTargetBranchHelp": "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it)." }, "mergeManually": "Merge Manually", "mobileNav": { diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 4be6632ea2..e00dc2a62c 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -5800,8 +5800,7 @@ "version": "Versión {{version}}", "versionShort": "", "checkUpdates": "", - "helpDiscussions": "", - "versionShort": "" + "helpDiscussions": "" }, "general": { "25": "", @@ -6209,7 +6208,11 @@ "gitLabPersonalAccessToken": "", "gitLabAuthTokenHint": "", "includeTaskIdInCommitDefault": "", - "trailerEmail": "" + "trailerEmail": "", + "gitRemoteThatMergedResultsArePushedTo": "", + "pushTargetBranch": "", + "sameAsIntegrationBranchDefault": "", + "pushTargetBranchHelp": "" }, "mergeManually": "Fusionar manualmente", "mobileNav": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index a27ac30dfb..3904358431 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -6208,7 +6208,11 @@ "gitLabPersonalAccessToken": "", "gitLabAuthTokenHint": "", "includeTaskIdInCommitDefault": "", - "trailerEmail": "" + "trailerEmail": "", + "gitRemoteThatMergedResultsArePushedTo": "", + "pushTargetBranch": "", + "sameAsIntegrationBranchDefault": "", + "pushTargetBranchHelp": "" }, "mergeManually": "Fusionner manuellement", "mobileNav": { diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 2d4e5987e2..e4043d40b8 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -5800,8 +5800,7 @@ "version": "버전 {{version}}", "versionShort": "", "checkUpdates": "", - "helpDiscussions": "", - "versionShort": "" + "helpDiscussions": "" }, "general": { "25": "", @@ -6209,7 +6208,11 @@ "gitLabPersonalAccessToken": "", "gitLabAuthTokenHint": "", "includeTaskIdInCommitDefault": "", - "trailerEmail": "" + "trailerEmail": "", + "gitRemoteThatMergedResultsArePushedTo": "", + "pushTargetBranch": "", + "sameAsIntegrationBranchDefault": "", + "pushTargetBranchHelp": "" }, "mergeManually": "수동으로 병합", "mobileNav": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 5da1ebbf2d..f4efee9ee3 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -5800,8 +5800,7 @@ "version": "版本 {{version}}", "versionShort": "", "checkUpdates": "", - "helpDiscussions": "", - "versionShort": "" + "helpDiscussions": "" }, "general": { "25": "", @@ -6209,7 +6208,11 @@ "gitLabPersonalAccessToken": "", "gitLabAuthTokenHint": "", "includeTaskIdInCommitDefault": "", - "trailerEmail": "" + "trailerEmail": "", + "gitRemoteThatMergedResultsArePushedTo": "", + "pushTargetBranch": "", + "sameAsIntegrationBranchDefault": "", + "pushTargetBranchHelp": "" }, "mergeManually": "手动合并", "mobileNav": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index ab6eb1a568..5728b215ee 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -5800,8 +5800,7 @@ "version": "版本 {{version}}", "versionShort": "", "checkUpdates": "", - "helpDiscussions": "", - "versionShort": "" + "helpDiscussions": "" }, "general": { "25": "", @@ -6209,7 +6208,11 @@ "gitLabPersonalAccessToken": "", "gitLabAuthTokenHint": "", "includeTaskIdInCommitDefault": "", - "trailerEmail": "" + "trailerEmail": "", + "gitRemoteThatMergedResultsArePushedTo": "", + "pushTargetBranch": "", + "sameAsIntegrationBranchDefault": "", + "pushTargetBranchHelp": "" }, "mergeManually": "手動合併", "mobileNav": {