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:
gsxdsm
2026-07-12 12:30:13 -07:00
parent b10f823672
commit 8884f50765
3 changed files with 138 additions and 1 deletions

View 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.

View File

@@ -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"

View File

@@ -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 },