feat(app): add pull-and-push git action (#627)

Combines `git pull` followed by `git push` behind a single action in the
git actions split-button. Push is skipped if pull fails, and errors are
surfaced through the same toast pathway as the existing pull and push
actions.
This commit is contained in:
Mohamed Boudra
2026-04-30 12:56:09 +08:00
committed by GitHub
parent 3f2c33ad46
commit f286a5beea
7 changed files with 235 additions and 2 deletions
@@ -93,6 +93,72 @@ describe("checkout-git-actions-store", () => {
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
it("runs pull then push sequentially for pull-and-push", async () => {
const order: string[] = [];
const client = {
checkoutPull: vi.fn(async () => {
order.push("pull");
return {};
}),
checkoutPush: vi.fn(async () => {
order.push("push");
return {};
}),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
expect(order).toEqual(["pull", "push"]);
expect(client.checkoutPull).toHaveBeenCalledWith(cwd);
expect(client.checkoutPush).toHaveBeenCalledWith(cwd);
});
it("does not push when pull fails for pull-and-push", async () => {
const client = {
checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
checkoutPush: vi.fn(async () => ({})),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("pull conflict");
expect(client.checkoutPush).not.toHaveBeenCalled();
});
it("surfaces push errors from pull-and-push after a successful pull", async () => {
const client = {
checkoutPull: vi.fn(async () => ({})),
checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("push rejected");
expect(client.checkoutPull).toHaveBeenCalledTimes(1);
expect(client.checkoutPush).toHaveBeenCalledTimes(1);
});
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
const queryClient = new QueryClient();
@@ -17,6 +17,7 @@ export type CheckoutGitAsyncActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "create-pr"
| "merge-branch"
| "merge-from-base"
@@ -234,6 +235,7 @@ interface CheckoutGitActionsStoreState {
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
@@ -347,6 +349,25 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "pull-and-push",
run: async () => {
const client = resolveClient(serverId);
const pullPayload = await client.checkoutPull(cwd);
if (pullPayload.error) {
throw new Error(pullPayload.error.message);
}
const pushPayload = await client.checkoutPush(cwd);
if (pushPayload.error) {
throw new Error(pushPayload.error.message);
}
},
});
},
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,