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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user