fix: wire push-after-merge into the unified runAiMerge path with remote/branch dropdown settings

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-11 22:16:22 -07:00
parent 391ff0d269
commit 4397cafb94
17 changed files with 944 additions and 26 deletions

View File

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

View File

@@ -3462,6 +3462,11 @@ export function fetchRemoteCommits(remote: string, ref?: string, limit?: number,
return api<GitCommit[]>(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<string[]> {
return api<string[]>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/branches`, projectId), repoPath));
}
/** Fetch all local branches */
export function fetchGitBranches(projectId?: string, repoPath?: string): Promise<GitBranch[]> {
return api<GitBranch[]>(withRepoPath(withProjectId("/git/branches", projectId), repoPath));

View File

@@ -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":

View File

@@ -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<string[]>([]);
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<LegacyAutoMergeStampCandidate[]>([]);
const [legacyStampLoading, setLegacyStampLoading] = useState(true);
const [legacyStampApplying, setLegacyStampApplying] = useState(false);
@@ -435,14 +474,69 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
</details>
</div>
{form.pushAfterMerge && (<div className="form-group">
{form.pushAfterMerge && (gitRemoteOptions.length === 0 ? (<div className="form-group">
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
<input id="pushRemote" type="text" placeholder={t("settings.merge.origin", "origin")} value={form.pushRemote || ""} onChange={(e) => setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))}/>
<details className="settings-option-details">
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.gitRemoteToPushToEGOrigin", "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".")}</small>
</details>
</div>)}
</div>) : (<>
<div className="form-group">
<label htmlFor="pushRemote">{t("settings.merge.pushRemote", "Push Remote")}</label>
<select id="pushRemote" className="select" value={pushTarget.remote} onChange={(e) => {
// Capture eagerly: the deferred setForm updater must not read the
// controlled select's value after React resets it on re-render.
const nextRemote = e.target.value;
// Branches differ per remote — reset the target branch to the default.
setPushBranchCustomMode(false);
setForm((f) => ({ ...f, pushRemote: composePushRemoteSetting(nextRemote, "") }));
}} data-testid="push-remote-select">
{!gitRemoteOptions.includes(pushTarget.remote) && (<option value={pushTarget.remote}>{pushTarget.remote}</option>)}
{gitRemoteOptions.map((name) => (<option key={name} value={name}>{name}</option>))}
</select>
<details className="settings-option-details">
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{t("settings.merge.gitRemoteThatMergedResultsArePushedTo", "Git remote that merged results are pushed to. Default: \"origin\".")}</small>
</details>
</div>
<div className="form-group">
<label htmlFor="pushRemoteBranch">{t("settings.merge.pushTargetBranch", "Push target branch")}</label>
{(() => {
const currentBranch = pushTarget.branch;
const branchIsKnown = currentBranch.length > 0 && pushBranchOptions.includes(currentBranch);
if (pushBranchCustomMode || (currentBranch.length > 0 && !branchIsKnown)) {
return (<div className="form-inline-group">
<input id="pushRemoteBranch" type="text" className="input" placeholder={t("settings.merge.branchName", "branch name")} value={currentBranch} onChange={(e) => {
const trimmed = e.target.value.trim();
setForm((f) => ({ ...f, pushRemote: composePushRemoteSetting(pushTarget.remote, trimmed) }));
}} data-testid="push-remote-branch-custom-input"/>
<button type="button" className="btn-link" onClick={() => {
setPushBranchCustomMode(false);
setForm((f) => ({ ...f, pushRemote: composePushRemoteSetting(pushTarget.remote, "") }));
}} data-testid="push-remote-branch-use-dropdown">{t("settings.merge.useDropdown", " Use dropdown ")}</button>
</div>);
}
const CUSTOM = "__fusion-custom__";
return (<select id="pushRemoteBranch" className="select" value={currentBranch} onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM) {
setPushBranchCustomMode(true);
return;
}
setForm((f) => ({ ...f, pushRemote: composePushRemoteSetting(pushTarget.remote, next) }));
}} data-testid="push-remote-branch-select">
<option value="">{t("settings.merge.sameAsIntegrationBranchDefault", "(same as integration branch — default)")}</option>
{pushBranchOptions.map((name) => (<option key={name} value={name}>{name}</option>))}
<option value={CUSTOM}>{t("settings.merge.custom", "Custom…")}</option>
</select>);
})()}
<details className="settings-option-details">
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
<small>{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).")}</small>
</details>
</div>
</>))}
</>);
}
export default MergeSection;

View File

@@ -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<typeof import("../../../../api")>();
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<MergeSectionProps["form"]> = {},
propOverrides: Partial<MergeSectionProps> = {},
): 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(<MergeSection {...makeProps()} />);
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(<MergeSection {...props} />);
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(<MergeSection {...props} />);
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(<MergeSection {...makeProps({ pushRemote: "origin not-fetched-yet" })} />);
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(<MergeSection {...makeProps({ pushRemote: "origin main" }, { gitRemoteOptions: [] })} />);
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(<MergeSection {...makeProps({ pushAfterMerge: false })} />);
expect(screen.queryByTestId("push-remote-select")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
expect(mockFetchGitRemoteBranches).not.toHaveBeenCalled();
});
});

View File

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

View File

@@ -1077,6 +1077,36 @@ export async function getGitBranches(cwd?: string): Promise<GitBranch[]> {
}
}
/*
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/<name>/) 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<string[]> {
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);
// `<remote>/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.

View File

@@ -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<typeof import("../agent-session-helpers.js")>();
return {
...actual,
createResolvedAgentSession: createResolvedAgentSessionMock,
};
});
vi.mock("../pi.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../pi.js")>();
return {
...actual,
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<void> | 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<string>();
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<string, unknown> = {}) {
const task: Record<string, unknown> = {
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<string, unknown>) => { 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);
});
});

View File

@@ -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<void>;
options: MergerOptions;
result: MergeResult;
}): Promise<void> {
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 <remote> refs/heads/<ib>:refs/heads/<target>` 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/<target>`. 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<void>;
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<string>();
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,

View File

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

View File

@@ -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/<integrationBranch>, 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<string, unknown>;
onSession?: (session: { dispose: () => void }) => void;
integrationBranch?: string;
/*
FNXC:MergePush 2026-07-11-22:10:
When true, push `HEAD:refs/heads/<branch>` 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/<branch> 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);

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -6208,7 +6208,11 @@
"gitLabPersonalAccessToken": "",
"gitLabAuthTokenHint": "",
"includeTaskIdInCommitDefault": "",
"trailerEmail": ""
"trailerEmail": "",
"gitRemoteThatMergedResultsArePushedTo": "",
"pushTargetBranch": "",
"sameAsIntegrationBranchDefault": "",
"pushTargetBranchHelp": ""
},
"mergeManually": "Fusionner manuellement",
"mobileNav": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {