FN-7868: add Commit and Push button to Git Manager changes panel
Adds a one-click commit-and-push affordance to the Git Manager modal's Changes panel, letting users commit staged changes and immediately push the active branch without a separate step. - Add handleCommitAndPush callback in GitManagerModal that reuses createCommit and pushBranch, refreshes file changes/status on success, and surfaces a distinct toast (with the local commit hash preserved) if the push step fails after a successful commit - Wire commitAndPush through to ChangesPanel and render a new "Commit and Push" button beside the existing Commit button, disabled while committing, when the message is empty, or when there are no staged files - Add test coverage for the commit-and-push flow, including the partial-failure case where commit succeeds but push fails - Add a minor changeset documenting the new Git Manager feature Files changed: .changeset/fn-7868-git-manager-commit-push.md | 7 +++ .../dashboard/app/components/GitManagerModal.tsx | 64 +++++++++++++++++++- .../components/__tests__/GitManagerModal.test.tsx | 68 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7868 Fusion-Task-Lineage: 8b6913db-6f2a-45ab-b99d-a5c11ce53439 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7868-git-manager-commit-push.md
Normal file
7
.changeset/fn-7868-git-manager-commit-push.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a Commit and Push button to the Git Manager commit form.
|
||||
category: feature
|
||||
dev: Adds a GitManagerModal commit-and-push handler that reuses createCommit and pushBranch.
|
||||
@@ -560,7 +560,55 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
}, [commitMessage, addToast, projectId, t]);
|
||||
}, [commitMessage, addToast, projectId, gitRepoPath, t]);
|
||||
|
||||
/*
|
||||
FNXC:GitManager 2026-07-12-00:00:
|
||||
The Changes commit form needs a one-click path that commits currently staged changes and immediately pushes the active branch using the existing git client APIs, while preserving the commit message when only the push fails so the local commit context remains visible.
|
||||
*/
|
||||
const handleCommitAndPush = useCallback(async () => {
|
||||
const trimmedMessage = commitMessage.trim();
|
||||
if (!trimmedMessage) return;
|
||||
|
||||
setCommitting(true);
|
||||
let commitHash: string | null = null;
|
||||
try {
|
||||
const commitResult = await createCommit(trimmedMessage, projectId, gitRepoPath);
|
||||
commitHash = commitResult.hash;
|
||||
addToast(t("git.committedHash", "Committed: {{hash}}", { hash: commitResult.hash }), "success");
|
||||
|
||||
const pushResult = await pushBranch(projectId, gitRepoPath);
|
||||
setLastRemoteResult(pushResult);
|
||||
addToast(pushResult.message || t("git.pushCompleted", "Push completed"), "success");
|
||||
setCommitMessage("");
|
||||
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
setChangeDiff(null);
|
||||
setChangeDiffError(null);
|
||||
} catch (err) {
|
||||
if (commitHash) {
|
||||
const errorMessage = getErrorMessage(err) || t("git.pushFailed", "Push failed");
|
||||
addToast(t("git.commitSucceededPushFailed", "Committed locally ({{hash}}), but push failed: {{message}}", { hash: commitHash, message: errorMessage }), "error");
|
||||
try {
|
||||
const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]);
|
||||
setFileChanges(changes);
|
||||
setStatus(statusData);
|
||||
setSelectedDiffTarget(null);
|
||||
setChangeDiff(null);
|
||||
setChangeDiffError(null);
|
||||
} catch {
|
||||
// Keep the push failure toast as the actionable user-facing error.
|
||||
}
|
||||
} else {
|
||||
addToast(getErrorMessage(err) || t("git.failedToCommit", "Failed to commit"), "error");
|
||||
}
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
}, [commitMessage, addToast, projectId, gitRepoPath, t]);
|
||||
|
||||
const handleStageAllAndCommit = useCallback(async () => {
|
||||
if (!commitMessage.trim()) return;
|
||||
@@ -1194,6 +1242,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
commitMessage={commitMessage}
|
||||
setCommitMessage={setCommitMessage}
|
||||
onCommit={handleCommit}
|
||||
onCommitAndPush={handleCommitAndPush}
|
||||
onStageAllAndCommit={handleStageAllAndCommit}
|
||||
committing={committing}
|
||||
/>
|
||||
@@ -1758,6 +1807,7 @@ function ChangesPanel({
|
||||
commitMessage,
|
||||
setCommitMessage,
|
||||
onCommit,
|
||||
onCommitAndPush,
|
||||
onStageAllAndCommit,
|
||||
committing,
|
||||
}: {
|
||||
@@ -1777,6 +1827,7 @@ function ChangesPanel({
|
||||
commitMessage: string;
|
||||
setCommitMessage: (msg: string) => void;
|
||||
onCommit: (e: React.FormEvent) => void;
|
||||
onCommitAndPush: () => void;
|
||||
onStageAllAndCommit: () => void;
|
||||
committing: boolean;
|
||||
}) {
|
||||
@@ -2012,6 +2063,17 @@ function ChangesPanel({
|
||||
{committing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
|
||||
{t("git.commit", "Commit")}
|
||||
</button>
|
||||
{/* FNXC:GitManager 2026-07-12-00:00: Expose the staged-only commit-then-push affordance beside Commit without making it a second primary action, so the established commit hierarchy stays intact. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={onCommitAndPush}
|
||||
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
|
||||
title={t("git.commitAndPushTitle", "Commit staged changes, then push the current branch")}
|
||||
>
|
||||
{committing ? <Loader2 size={14} className="spin" /> : <ArrowUp size={14} />}
|
||||
{t("git.commitAndPush", "Commit and Push")}
|
||||
</button>
|
||||
{unstagedFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -758,6 +758,74 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("commits staged changes and then pushes from the Changes form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
|
||||
|
||||
await screen.findByPlaceholderText("Commit message...");
|
||||
await user.type(screen.getByPlaceholderText("Commit message..."), "feat: publish staged work");
|
||||
await user.click(screen.getByRole("button", { name: /commit and push/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expectLatestCallStartsWith(createCommit as any, "feat: publish staged work");
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
expect((createCommit as any).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
(pushBranch as any).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Push completed", "success");
|
||||
});
|
||||
|
||||
it("disables Commit and Push when the message is empty or no staged files exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
|
||||
|
||||
const commitAndPush = await screen.findByRole("button", { name: /commit and push/i });
|
||||
expect(commitAndPush).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
(fetchFileChanges as any).mockResolvedValueOnce([
|
||||
{ file: "src/app.ts", status: "modified", staged: false },
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
|
||||
|
||||
const textarea = await screen.findByPlaceholderText("Commit message...");
|
||||
await user.type(textarea, "fix: no staged changes");
|
||||
expect(screen.getByRole("button", { name: /commit and push/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps local commit context visible when commit succeeds but push fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(pushBranch as any).mockRejectedValueOnce(new Error("Remote rejected"));
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /changes/i }));
|
||||
|
||||
const textarea = await screen.findByPlaceholderText("Commit message...");
|
||||
await user.type(textarea, "feat: keep context");
|
||||
await user.click(screen.getByRole("button", { name: /commit and push/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expectLatestCallStartsWith(createCommit as any, "feat: keep context");
|
||||
expect(pushBranch).toHaveBeenCalled();
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Committed locally"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
expect(screen.getByDisplayValue("feat: keep context")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Commit button when no message or no staged files", async () => {
|
||||
(fetchFileChanges as any).mockResolvedValue([
|
||||
{ file: "src/app.ts", status: "modified", staged: false },
|
||||
|
||||
Reference in New Issue
Block a user