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:
gsxdsm
2026-06-02 08:25:46 -07:00
parent 8b839d79e8
commit 419d6cda86
3 changed files with 140 additions and 0 deletions

View File

@@ -239,6 +239,7 @@ Features:
- Commit and diff browsing - Commit and diff browsing
- Push/pull/fetch actions - Push/pull/fetch actions
- Pull with rebase option (split-button chooses between `git pull` and `git pull --rebase`) - Pull with rebase option (split-button chooses between `git pull` and `git pull --rebase`)
- One-click **Sync** action in Remotes (`git pull --rebase` followed by push; it stops and surfaces an error instead of pushing when the pull conflicts or fails)
- Remote editing controls - Remote editing controls
- Stash inspection (view stat + patch) before apply/pop/drop actions - Stash inspection (view stat + patch) before apply/pop/drop actions
- Remotes tab keeps "Recent commits on {remote}" in sync immediately after successful push/pull actions - Remotes tab keeps "Recent commits on {remote}" in sync immediately after successful push/pull actions

View File

@@ -818,6 +818,28 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} }
}, [addToast, projectId]); }, [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 // Fetch rootDir from config (used as worktreePath for the per-task sync
// button surfaced from RemotesPanel below). // button surfaced from RemotesPanel below).
useEffect(() => { useEffect(() => {
@@ -1039,6 +1061,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
onFetch={handleFetch} onFetch={handleFetch}
onPull={handlePull} onPull={handlePull}
onPush={handlePush} onPush={handlePush}
onSync={handleSyncWithOrigin}
onSyncIntegrationTip={handleSyncIntegrationTip} onSyncIntegrationTip={handleSyncIntegrationTip}
syncIntegrationDisabled={ syncIntegrationDisabled={
!status?.integrationBranch || !status?.integrationBranch ||
@@ -2244,6 +2267,7 @@ function RemotesPanel({
onFetch, onFetch,
onPull, onPull,
onPush, onPush,
onSync,
onSyncIntegrationTip, onSyncIntegrationTip,
syncIntegrationDisabled, syncIntegrationDisabled,
addToast, addToast,
@@ -2256,6 +2280,7 @@ function RemotesPanel({
onFetch: () => void; onFetch: () => void;
onPull: (options?: { rebase?: boolean }) => void; onPull: (options?: { rebase?: boolean }) => void;
onPush: () => void; onPush: () => void;
onSync: () => void;
onSyncIntegrationTip: () => void; onSyncIntegrationTip: () => void;
syncIntegrationDisabled: boolean; syncIntegrationDisabled: boolean;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
@@ -2761,6 +2786,20 @@ function RemotesPanel({
)} )}
Push Push
</button> </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 && ( {status?.integrationBranch && (
<button <button
className="btn gm-sync-integration-btn" className="btn gm-sync-integration-btn"

View File

@@ -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 () => { it("shows error toast when fetch fails", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
(fetchGitRemotesDetailed as any).mockResolvedValue([ (fetchGitRemotesDetailed as any).mockResolvedValue([