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:
7
.changeset/fn-push-after-merge-unified-path.md
Normal file
7
.changeset/fn-push-after-merge-unified-path.md
Normal 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.
|
||||
@@ -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));
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
238
packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts
Normal file
238
packages/engine/src/__tests__/merger-ai-push-after-merge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6208,7 +6208,11 @@
|
||||
"gitLabPersonalAccessToken": "",
|
||||
"gitLabAuthTokenHint": "",
|
||||
"includeTaskIdInCommitDefault": "",
|
||||
"trailerEmail": ""
|
||||
"trailerEmail": "",
|
||||
"gitRemoteThatMergedResultsArePushedTo": "",
|
||||
"pushTargetBranch": "",
|
||||
"sameAsIntegrationBranchDefault": "",
|
||||
"pushTargetBranchHelp": ""
|
||||
},
|
||||
"mergeManually": "Fusionner manuellement",
|
||||
"mobileNav": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user