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:
@@ -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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -15,6 +15,8 @@ const defaultSettings = {
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
recycleWorktrees: false,
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
|
||||
Reference in New Issue
Block a user