FN-5877: add sync-with-origin remotes action
Add a one-click Remotes sync flow that rebases on pull before pushing. - add a Sync button in Git Manager Remotes that runs pull --rebase and then push - stop before pushing when the rebase pull reports a conflict or fails, and surface toasts/status updates - cover successful, conflicting, failing, and loading sync behavior in GitManagerModal tests - document the new Sync action in the dashboard guide Files changed: docs/dashboard-guide.md | 1 + .../dashboard/app/components/GitManagerModal.tsx | 39 ++++++++ .../components/__tests__/GitManagerModal.test.tsx | 100 +++++++++++++++++++++ 3 files changed, 140 insertions(+) Fusion-Task-Id: FN-5877 Fusion-Task-Lineage: 2e28c00d-b965-4f44-a6fa-26536d91d16d
This commit is contained in:
@@ -818,6 +818,28 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
const handleSyncWithOrigin = useCallback(async () => {
|
||||
setRemoteLoading("sync");
|
||||
try {
|
||||
const pullResult = await pullBranch({ rebase: true }, projectId);
|
||||
setLastRemoteResult(pullResult);
|
||||
if (pullResult.conflict) {
|
||||
addToast("Merge conflict detected. Resolve manually.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pushResult = await pushBranch(projectId);
|
||||
setLastRemoteResult(pushResult);
|
||||
addToast("Synced with origin (pull --rebase + push)", "success");
|
||||
const statusData = await fetchGitStatus(projectId, { extended: true });
|
||||
setStatus(statusData);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Sync with origin failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast, projectId]);
|
||||
|
||||
// Fetch rootDir from config (used as worktreePath for the per-task sync
|
||||
// button surfaced from RemotesPanel below).
|
||||
useEffect(() => {
|
||||
@@ -1039,6 +1061,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
onFetch={handleFetch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onSync={handleSyncWithOrigin}
|
||||
onSyncIntegrationTip={handleSyncIntegrationTip}
|
||||
syncIntegrationDisabled={
|
||||
!status?.integrationBranch ||
|
||||
@@ -2244,6 +2267,7 @@ function RemotesPanel({
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onSync,
|
||||
onSyncIntegrationTip,
|
||||
syncIntegrationDisabled,
|
||||
addToast,
|
||||
@@ -2256,6 +2280,7 @@ function RemotesPanel({
|
||||
onFetch: () => void;
|
||||
onPull: (options?: { rebase?: boolean }) => void;
|
||||
onPush: () => void;
|
||||
onSync: () => void;
|
||||
onSyncIntegrationTip: () => void;
|
||||
syncIntegrationDisabled: boolean;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
@@ -2761,6 +2786,20 @@ function RemotesPanel({
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onSync}
|
||||
disabled={remoteLoading !== null || loading}
|
||||
title="Pull --rebase from origin, then push current branch"
|
||||
data-testid="remotes-sync-origin-btn"
|
||||
>
|
||||
{remoteLoading === "sync" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitMerge size={14} />
|
||||
)}
|
||||
Sync
|
||||
</button>
|
||||
{status?.integrationBranch && (
|
||||
<button
|
||||
className="btn gm-sync-integration-btn"
|
||||
|
||||
@@ -1531,6 +1531,106 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs with origin by running pull --rebase before push", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
expect((pullBranch as any).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
(pushBranch as any).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
"Synced with origin (pull --rebase + push)",
|
||||
"success"
|
||||
);
|
||||
expectLatestCallStartsWith(fetchGitStatus as any, undefined, { extended: true });
|
||||
});
|
||||
|
||||
it("does not push when sync pull reports a conflict", async () => {
|
||||
const user = userEvent.setup();
|
||||
(pullBranch as any).mockResolvedValue({
|
||||
success: false,
|
||||
message: "Rebase stopped on conflict",
|
||||
conflict: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
});
|
||||
expect(pushBranch).not.toHaveBeenCalled();
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Merge conflict detected. Resolve manually.", "error");
|
||||
});
|
||||
|
||||
it("does not push when sync pull rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
(pullBranch as any).mockRejectedValue(new Error("sync pull failed"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
});
|
||||
expect(pushBranch).not.toHaveBeenCalled();
|
||||
expect(mockAddToast).toHaveBeenCalledWith("sync pull failed", "error");
|
||||
});
|
||||
|
||||
it("shows a spinner and disables the Sync button while syncing", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolvePull: ((value: { success: boolean; message: string }) => void) | undefined;
|
||||
const pendingPull = new Promise<{ success: boolean; message: string }>((resolve) => {
|
||||
resolvePull = resolve;
|
||||
});
|
||||
(pullBranch as any).mockReturnValue(pendingPull);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
const syncButton = await screen.findByTestId("remotes-sync-origin-btn");
|
||||
expect(syncButton).not.toBeDisabled();
|
||||
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncButton).toBeDisabled();
|
||||
expect(syncButton.querySelector(".spin")).not.toBeNull();
|
||||
});
|
||||
|
||||
resolvePull?.({ success: true, message: "Already up to date." });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined);
|
||||
expect(syncButton).not.toBeDisabled();
|
||||
});
|
||||
expectLatestCallStartsWith(fetchGitStatus as any, undefined, { extended: true });
|
||||
});
|
||||
|
||||
it("shows error toast when fetch fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
|
||||
Reference in New Issue
Block a user