feat(FN-2160): add push-after-merge remote sync workflow

- Add project settings for pushAfterMerge and pushRemote with defaults and typed merge result fields for push status/errors
- Implement post-merge remote sync in the merger with pull --rebase, auto/AI conflict resolution, and one non-fast-forward retry before push
- Expose push-after-merge controls in Settings modal with conditional Push Remote input and coverage for desktop/mobile save flows
- Document the new settings in the settings reference and stabilize CLI cross-build help test timeout
This commit is contained in:
Fusion
2026-04-19 13:29:59 -07:00
committed by gsxdsm
parent 4c08b1c32a
commit a93f0f2ef9
9 changed files with 821 additions and 1 deletions

View File

@@ -68,6 +68,8 @@ const defaultSettings = {
groupOverlappingFiles: true,
autoMerge: true,
mergeStrategy: "direct",
pushAfterMerge: false,
pushRemote: "origin",
recycleWorktrees: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
@@ -633,6 +635,67 @@ describe("SettingsModal", () => {
});
});
describe("Merge section", () => {
it("shows push-after-merge toggle and keeps Push Remote hidden by default", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getAllByText("Merge")[0]);
const pushAfterMergeToggle = screen.getByRole("checkbox", {
name: /push to remote after merge/i,
});
expect(pushAfterMergeToggle).not.toBeChecked();
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
});
it("shows Push Remote input when push-after-merge is enabled", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getAllByText("Merge")[0]);
await userEvent.click(
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
);
expect(screen.getByLabelText("Push Remote")).toBeInTheDocument();
expect(screen.getByPlaceholderText("origin")).toBeInTheDocument();
});
it("includes pushAfterMerge and pushRemote in the save payload", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getAllByText("Merge")[0]);
await userEvent.click(
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
);
const pushRemoteInput = screen.getByLabelText("Push Remote");
await userEvent.clear(pushRemoteInput);
await userEvent.type(pushRemoteInput, "upstream main");
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.pushAfterMerge).toBe(true);
expect(payload.pushRemote).toBe("upstream main");
});
});
describe("Experimental Features section", () => {
it("renders the Experimental Features section in the sidebar", async () => {
renderModal();

View File

@@ -2215,6 +2215,36 @@ export function SettingsModal({
</label>
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
</div>
<div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label">
<input
id="pushAfterMerge"
type="checkbox"
checked={form.pushAfterMerge === true}
onChange={(e) =>
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
}
/>
Push to remote after merge
</label>
<small>When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed.</small>
</div>
{form.pushAfterMerge && (
<div className="form-group">
<label htmlFor="pushRemote">Push Remote</label>
<input
id="pushRemote"
type="text"
placeholder="origin"
value={form.pushRemote || ""}
onChange={(e) =>
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
}
/>
<small>Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".</small>
</div>
)}
</>
);
case "memory": {

View File

@@ -15,6 +15,8 @@ const defaultSettings = {
groupOverlappingFiles: false,
autoMerge: true,
mergeStrategy: "direct",
pushAfterMerge: false,
pushRemote: "origin",
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",