feat(FN-3724): add split-button for git pull with rebase option

The merge adds a pull split-button in the GitManager modal that lets users choose between a regular `git pull` and a `git pull --rebase`, with styling and test coverage for the menu interactions and API payload. It also fixes plugin toggle rendering (FN-3723) and adds a release note for plugin insta

Fusion-Task-Id: FN-3724
This commit is contained in:
Fusion
2026-05-07 23:57:28 -07:00
committed by gsxdsm
parent 2b8cbd1d05
commit 83be5775cd
8 changed files with 264 additions and 36 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add a split Pull action in the Git Manager Remotes panel with a dropdown option to run `Pull --rebase`.

View File

@@ -1159,7 +1159,7 @@ Git dashboard routes are registered in `register-git-github.ts`.
| POST | `/api/git/branches/:name/checkout` | Checkout an existing branch. | | POST | `/api/git/branches/:name/checkout` | Checkout an existing branch. |
| DELETE | `/api/git/branches/:name` | Delete a branch (`?force=true` allows deleting unmerged branches). | | DELETE | `/api/git/branches/:name` | Delete a branch (`?force=true` allows deleting unmerged branches). |
| POST | `/api/git/fetch` | Fetch from a remote (`remote` defaults to `origin`). | | POST | `/api/git/fetch` | Fetch from a remote (`remote` defaults to `origin`). |
| POST | `/api/git/pull` | Pull the current branch and return structured conflict metadata on merge conflicts. | | POST | `/api/git/pull` | Pull the current branch (`rebase` boolean optional) and return structured conflict metadata on merge/rebase conflicts. |
| POST | `/api/git/push` | Push the current branch. | | POST | `/api/git/push` | Push the current branch. |
| GET | `/api/git/stashes` | List stash entries. | | GET | `/api/git/stashes` | List stash entries. |
| GET | `/api/git/stashes/:index/diff` | Return stash stat + patch for a validated stash index (404 when missing). | | GET | `/api/git/stashes/:index/diff` | Return stash stat + patch for a validated stash index (404 when missing). |

View File

@@ -438,7 +438,7 @@ describe("Git Management API", () => {
}); });
describe("pullBranch", () => { describe("pullBranch", () => {
it("sends POST to pull", async () => { it("sends POST to pull with default rebase false", async () => {
const result = { success: true, message: "Pulled 2 commits" }; const result = { success: true, message: "Pulled 2 commits" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result)); globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
@@ -448,6 +448,20 @@ describe("Git Management API", () => {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ rebase: false }),
});
});
it("sends rebase true when requested", async () => {
const result = { success: true, message: "Rebased and pulled" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await pullBranch({ rebase: true });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ rebase: true }),
}); });
}); });

View File

@@ -2358,9 +2358,18 @@ export function fetchRemote(remote?: string, projectId?: string): Promise<GitFet
} }
/** Pull current branch */ /** Pull current branch */
export function pullBranch(projectId?: string): Promise<GitPullResult> { export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
return api<GitPullResult>(withProjectId("/git/pull", projectId), { export function pullBranch(projectId?: string): Promise<GitPullResult>;
export function pullBranch(
optionsOrProjectId?: { rebase?: boolean } | string,
projectId?: string,
): Promise<GitPullResult> {
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
method: "POST", method: "POST",
body: JSON.stringify({ rebase: options?.rebase ?? false }),
}); });
} }

View File

@@ -777,15 +777,16 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
} }
}, [addToast, projectId]); }, [addToast, projectId]);
const handlePull = useCallback(async () => { const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
setRemoteLoading("pull"); setRemoteLoading("pull");
try { try {
const result = await pullBranch(projectId); const result = await pullBranch(options, projectId);
setLastRemoteResult(result); setLastRemoteResult(result);
if (result.conflict) { if (result.conflict) {
addToast("Merge conflict detected. Resolve manually.", "error"); addToast("Merge conflict detected. Resolve manually.", "error");
} else { } else {
addToast(result.message || "Pull completed", "success"); const fallbackMessage = options?.rebase ? "Pull --rebase completed" : "Pull completed";
addToast(result.message || fallbackMessage, "success");
} }
const statusData = await fetchGitStatus(projectId); const statusData = await fetchGitStatus(projectId);
setStatus(statusData); setStatus(statusData);
@@ -1909,7 +1910,7 @@ function RemotesPanel({
remoteLoading: string | null; remoteLoading: string | null;
lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null; lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null;
onFetch: () => void; onFetch: () => void;
onPull: () => void; onPull: (options?: { rebase?: boolean }) => void;
onPush: () => void; onPush: () => void;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
projectId?: string; projectId?: string;
@@ -1933,6 +1934,8 @@ function RemotesPanel({
const [editUrlValue, setEditUrlValue] = useState(""); const [editUrlValue, setEditUrlValue] = useState("");
const [editNameValue, setEditNameValue] = useState(""); const [editNameValue, setEditNameValue] = useState("");
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [pullMenuOpen, setPullMenuOpen] = useState(false);
const pullSplitRef = useRef<HTMLDivElement | null>(null);
// Ahead commits (local commits to push) // Ahead commits (local commits to push)
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]); const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
@@ -1948,6 +1951,38 @@ function RemotesPanel({
// Derived state for selected remote // Derived state for selected remote
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote); const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
useEffect(() => {
if (remoteLoading !== null || loading) {
setPullMenuOpen(false);
}
}, [remoteLoading, loading]);
useEffect(() => {
if (!pullMenuOpen) {
return;
}
const handleDocumentClick = (event: MouseEvent) => {
if (!pullSplitRef.current?.contains(event.target as Node)) {
setPullMenuOpen(false);
}
};
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setPullMenuOpen(false);
}
};
document.addEventListener("mousedown", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("mousedown", handleDocumentClick);
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, [pullMenuOpen]);
// Inline commit diff expansion (one per list context) // Inline commit diff expansion (one per list context)
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null); const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null); const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
@@ -2317,9 +2352,13 @@ function RemotesPanel({
)} )}
Fetch Fetch
</button> </button>
<div className="gm-pull-split" ref={pullSplitRef}>
<button <button
className="btn btn-primary" className="btn btn-primary gm-pull-split-main"
onClick={onPull} onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
disabled={remoteLoading !== null || loading} disabled={remoteLoading !== null || loading}
> >
{remoteLoading === "pull" ? ( {remoteLoading === "pull" ? (
@@ -2329,6 +2368,41 @@ function RemotesPanel({
)} )}
Pull Pull
</button> </button>
<button
className="btn btn-primary btn-icon gm-pull-split-toggle"
onClick={() => setPullMenuOpen((open) => !open)}
disabled={remoteLoading !== null || loading}
aria-label="Pull options"
aria-haspopup="menu"
aria-expanded={pullMenuOpen}
>
<ChevronDown size={14} />
</button>
{pullMenuOpen ? (
<div className="gm-pull-menu" role="menu" aria-label="Pull options menu">
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
>
Pull
</button>
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: true });
}}
>
Pull --rebase
</button>
</div>
) : null}
</div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={onPush} onClick={onPush}

View File

@@ -2982,6 +2982,66 @@
flex: 1 1 calc(var(--space-2xl) * 5); flex: 1 1 calc(var(--space-2xl) * 5);
} }
.gm-pull-split {
position: relative;
display: flex;
flex: 1 1 calc(var(--space-2xl) * 5);
}
.gm-pull-split .btn {
flex: 1 1 auto;
}
.gm-pull-split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.gm-pull-split-toggle {
flex: 0 0 auto;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
padding-inline: var(--space-sm);
}
.gm-pull-menu {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 4;
min-width: calc(var(--space-2xl) * 5);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 80%, var(--card));
box-shadow: var(--shadow-lg);
}
.gm-pull-menu-item {
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
font: inherit;
padding: var(--space-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.gm-pull-menu-item:hover {
background: var(--card-hover);
}
.gm-pull-menu-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.gm-remote-result { .gm-remote-result {
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
background: var(--card); background: var(--card);
@@ -3584,6 +3644,16 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.gm-pull-split {
flex: 1 1 100%;
}
.gm-pull-menu {
left: 0;
right: auto;
min-width: 100%;
}
.gm-remotes-layout { .gm-remotes-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1363,20 +1363,62 @@ describe("GitManagerModal", () => {
}); });
}); });
it("calls pullBranch when Pull button clicked", async () => { it("calls pullBranch with rebase false when Pull button clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} /> <GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
); );
fireEvent.click(screen.getByRole("tab", { name: /remotes/i })); fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
// Wait for sync card and scope button search within it
const syncCard = await screen.findByTestId("remote-sync-card"); const syncCard = await screen.findByTestId("remote-sync-card");
const pullButton = within(syncCard).getByRole("button", { name: /pull/i }); const pullButton = within(syncCard).getByRole("button", { name: /^pull$/i });
await user.click(pullButton); await user.click(pullButton);
await waitFor(() => { await waitFor(() => {
expect(pullBranch).toHaveBeenCalled(); expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
});
});
it("calls pullBranch with rebase true from pull options menu", 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 syncCard = await screen.findByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /pull options/i }));
await user.click(screen.getByRole("menuitem", { name: /pull --rebase/i }));
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
});
});
it("closes pull options menu on outside click and Escape", 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 syncCard = await screen.findByTestId("remote-sync-card");
const pullOptionsButton = within(syncCard).getByRole("button", { name: /pull options/i });
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.click(document.body);
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
});
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
}); });
}); });
@@ -2114,10 +2156,10 @@ describe("GitManagerModal", () => {
}); });
const syncCard = screen.getByTestId("remote-sync-card"); const syncCard = screen.getByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /pull/i })); await user.click(within(syncCard).getByRole("button", { name: /^pull$/i }));
await waitFor(() => { await waitFor(() => {
expect(pullBranch).toHaveBeenCalled(); expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2); expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument(); expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument(); expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
@@ -2126,13 +2168,18 @@ describe("GitManagerModal", () => {
it("refreshes recent remote commits after push without reopening modal", async () => { it("refreshes recent remote commits after push without reopening modal", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
(fetchRemoteCommits as any) let remoteCommitsCallCount = 0;
.mockResolvedValueOnce([ (fetchRemoteCommits as any).mockImplementation(() => {
remoteCommitsCallCount += 1;
if (remoteCommitsCallCount <= 1) {
return Promise.resolve([
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] }, { hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]) ]);
.mockResolvedValueOnce([ }
return Promise.resolve([
{ hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] }, { hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]); ]);
});
render( render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} /> <GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />

View File

@@ -464,16 +464,21 @@ export interface GitPullResult {
conflict?: boolean; conflict?: boolean;
} }
export async function pullGitBranch(cwd?: string): Promise<GitPullResult> { export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
const rebase = options?.rebase === true;
try { try {
const output = await runGitCommand(["pull"], cwd, 30000); const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull"], cwd, 30000);
return { success: true, message: output.trim() }; const message = output.trim();
if (message) {
return { success: true, message };
}
return { success: true, message: rebase ? "Pull completed (rebase)" : "Pull completed" };
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;
} }
const message = getCommandErrorMessage(err); const message = getCommandErrorMessage(err);
if (message.includes("CONFLICT") || message.includes("Merge conflict")) { if (message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply")) {
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true }; return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
} }
throw new Error(message || "Pull failed"); throw new Error(message || "Pull failed");
@@ -1653,7 +1658,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
if (!(await isGitRepo(rootDir))) { if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository"); throw badRequest("Not a git repository");
} }
const result = await pullGitBranch(rootDir); const { rebase } = req.body ?? {};
if (rebase !== undefined && typeof rebase !== "boolean") {
throw badRequest("rebase must be a boolean");
}
const result = await pullGitBranch(rootDir, { rebase: rebase === true });
if (result.conflict) { if (result.conflict) {
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", { throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
...result, ...result,