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
@@ -36,6 +36,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle", status: "idle",
handler: () => undefined, handler: () => undefined,
}, },
"pull-and-push": {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: { pr: {
disabled: false, disabled: false,
status: "idle", status: "idle",
@@ -65,7 +70,7 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => { it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true })); const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]); expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
}); });
it("prioritizes pull when the branch is behind origin", () => { it("prioritizes pull when the branch is behind origin", () => {
@@ -149,6 +154,7 @@ describe("git-actions-policy", () => {
expect(actions.secondary.map((action) => action.id)).toEqual([ expect(actions.secondary.map((action) => action.id)).toEqual([
"pull", "pull",
"push", "push",
"pull-and-push",
"merge-from-base", "merge-from-base",
"merge-branch", "merge-branch",
"pr", "pr",
@@ -158,6 +164,48 @@ describe("git-actions-policy", () => {
).toBe(true); ).toBe(true);
}); });
it("enables pull-and-push when the branch has both incoming and outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 3,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
disabled: false,
unavailableMessage: undefined,
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
disabled: false,
unavailableMessage: "Pull and push isn't available because this branch is already in sync",
});
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasUncommittedChanges: true,
aheadOfOrigin: 1,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available while you have local changes so commit or stash them first",
);
});
it("only shows archive worktree for paseo worktrees", () => { it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput()); const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true })); const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
@@ -6,6 +6,7 @@ export type GitActionId =
| "commit" | "commit"
| "pull" | "pull"
| "push" | "push"
| "pull-and-push"
| "pr" | "pr"
| "merge-branch" | "merge-branch"
| "merge-from-base" | "merge-from-base"
@@ -56,7 +57,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>; runtime: Record<GitActionId, GitActionRuntimeState>;
} }
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"]; const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"]; const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
export function buildGitActions(input: BuildGitActionsInput): GitActions { export function buildGitActions(input: BuildGitActionsInput): GitActions {
@@ -101,6 +102,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.push.handler, handler: input.runtime.push.handler,
}); });
allActions.set("pull-and-push", {
id: "pull-and-push",
label: "Pull and push",
pendingLabel: "Pulling and pushing...",
successLabel: "Pulled and pushed",
disabled: input.runtime["pull-and-push"].disabled,
status: input.runtime["pull-and-push"].status,
unavailableMessage: input.runtime["pull-and-push"].disabled
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input)); allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", { allActions.set("merge-branch", {
@@ -265,6 +280,19 @@ function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefi
return undefined; return undefined;
} }
function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull and push isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) { if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected"; return "Create PR isn't available right now because GitHub isn't connected";
@@ -31,6 +31,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
AlignJustify, AlignJustify,
Archive, Archive,
ArrowDownUp,
ChevronDown, ChevronDown,
Columns2, Columns2,
Download, Download,
@@ -1071,6 +1072,7 @@ interface GitActionRunners {
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>; runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
runPull: (args: { serverId: string; cwd: string }) => Promise<void>; runPull: (args: { serverId: string; cwd: string }) => Promise<void>;
runPush: (args: { serverId: string; cwd: string }) => Promise<void>; runPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runPullAndPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>; runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>;
runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
@@ -1099,6 +1101,7 @@ interface GitActionHandlers {
handleCommit: () => void; handleCommit: () => void;
handlePull: () => void; handlePull: () => void;
handlePush: () => void; handlePush: () => void;
handlePullAndPush: () => void;
handleCreatePr: () => void; handleCreatePr: () => void;
handleMergeBranch: () => void; handleMergeBranch: () => void;
handleMergeFromBase: () => void; handleMergeFromBase: () => void;
@@ -1154,6 +1157,18 @@ function useGitActionHandlers({
}); });
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]); }, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runners
.runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled and pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull and push");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => { const handleCreatePr = useCallback(() => {
void persistShipDefault("pr"); void persistShipDefault("pr");
void runners void runners
@@ -1233,6 +1248,7 @@ function useGitActionHandlers({
handleCommit, handleCommit,
handlePull, handlePull,
handlePush, handlePush,
handlePullAndPush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
@@ -1350,6 +1366,7 @@ function computeDisabledStates(
commitDisabled: actionsDisabled || statuses.commitStatus === pending, commitDisabled: actionsDisabled || statuses.commitStatus === pending,
pullDisabled: actionsDisabled || statuses.pullStatus === pending, pullDisabled: actionsDisabled || statuses.pullStatus === pending,
pushDisabled: actionsDisabled || statuses.pushStatus === pending, pushDisabled: actionsDisabled || statuses.pushStatus === pending,
pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending,
prDisabled: actionsDisabled || statuses.prCreateStatus === pending, prDisabled: actionsDisabled || statuses.prCreateStatus === pending,
mergeDisabled: actionsDisabled || statuses.mergeStatus === pending, mergeDisabled: actionsDisabled || statuses.mergeStatus === pending,
mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending, mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending,
@@ -1393,6 +1410,7 @@ interface GitActionsStatusInputs {
commitStatus: CheckoutGitActionStatus; commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus; pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus; pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus; prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus; mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus; mergeFromBaseStatus: CheckoutGitActionStatus;
@@ -1403,6 +1421,7 @@ interface GitActionsDisabledInputs {
commitDisabled: boolean; commitDisabled: boolean;
pullDisabled: boolean; pullDisabled: boolean;
pushDisabled: boolean; pushDisabled: boolean;
pullAndPushDisabled: boolean;
prDisabled: boolean; prDisabled: boolean;
mergeDisabled: boolean; mergeDisabled: boolean;
mergeFromBaseDisabled: boolean; mergeFromBaseDisabled: boolean;
@@ -1462,6 +1481,12 @@ function buildGitActionsForPane({
icon: <Upload size={16} color={iconColor} />, icon: <Upload size={16} color={iconColor} />,
handler: handlers.handlePush, handler: handlers.handlePush,
}, },
"pull-and-push": {
disabled: disabled.pullAndPushDisabled,
status: statuses.pullAndPushStatus,
icon: <ArrowDownUp size={16} color={iconColor} />,
handler: handlers.handlePullAndPush,
},
pr: { pr: {
disabled: disabled.prDisabled, disabled: disabled.prDisabled,
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus, status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
@@ -1879,6 +1904,9 @@ export function GitDiffPane({
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), state.getStatus({ serverId, cwd, actionId: "push" }),
); );
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) => const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }), state.getStatus({ serverId, cwd, actionId: "create-pr" }),
); );
@@ -1895,6 +1923,7 @@ export function GitDiffPane({
const runCommit = useCheckoutGitActionsStore((state) => state.commit); const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
@@ -1938,6 +1967,7 @@ export function GitDiffPane({
runCommit, runCommit,
runPull, runPull,
runPush, runPush,
runPullAndPush,
runCreatePr, runCreatePr,
runMergeBranch, runMergeBranch,
runMergeFromBase, runMergeFromBase,
@@ -1950,6 +1980,7 @@ export function GitDiffPane({
runMergeBranch, runMergeBranch,
runMergeFromBase, runMergeFromBase,
runPull, runPull,
runPullAndPush,
runPush, runPush,
], ],
); );
@@ -1958,6 +1989,7 @@ export function GitDiffPane({
handleCommit, handleCommit,
handlePull, handlePull,
handlePush, handlePush,
handlePullAndPush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
@@ -2081,6 +2113,7 @@ export function GitDiffPane({
commitStatus, commitStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
pullAndPushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
@@ -2092,6 +2125,7 @@ export function GitDiffPane({
mergeFromBaseStatus, mergeFromBaseStatus,
mergeStatus, mergeStatus,
prCreateStatus, prCreateStatus,
pullAndPushStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
], ],
@@ -2155,6 +2189,7 @@ export function GitDiffPane({
handleCommit, handleCommit,
handlePull, handlePull,
handlePush, handlePush,
handlePullAndPush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
@@ -2169,6 +2204,7 @@ export function GitDiffPane({
handleMergeFromBase, handleMergeFromBase,
handlePr, handlePr,
handlePull, handlePull,
handlePullAndPush,
handlePush, handlePush,
], ],
); );
+31
View File
@@ -54,6 +54,7 @@ function useGitActionStatuses(
commitStatus: CheckoutGitActionStatus; commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus; pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus; pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus; prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus; mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus; mergeFromBaseStatus: CheckoutGitActionStatus;
@@ -68,6 +69,9 @@ function useGitActionStatuses(
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), state.getStatus({ serverId, cwd, actionId: "push" }),
); );
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) => const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }), state.getStatus({ serverId, cwd, actionId: "create-pr" }),
); );
@@ -84,6 +88,7 @@ function useGitActionStatuses(
commitStatus, commitStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
pullAndPushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
@@ -180,6 +185,7 @@ function useGitActionRunners() {
const runCommit = useCheckoutGitActionsStore((state) => state.commit); const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
@@ -188,6 +194,7 @@ function useGitActionRunners() {
runCommit, runCommit,
runPull, runPull,
runPush, runPush,
runPullAndPush,
runCreatePr, runCreatePr,
runMergeBranch, runMergeBranch,
runMergeFromBase, runMergeFromBase,
@@ -202,6 +209,7 @@ interface UseGitActionsInput {
commit: ReactElement; commit: ReactElement;
pull: ReactElement; pull: ReactElement;
push: ReactElement; push: ReactElement;
pullAndPush: ReactElement;
viewPr: ReactElement; viewPr: ReactElement;
createPr: ReactElement; createPr: ReactElement;
merge: ReactElement; merge: ReactElement;
@@ -284,6 +292,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitStatus, commitStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
pullAndPushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
@@ -294,6 +303,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
runCommit, runCommit,
runPull, runPull,
runPush, runPush,
runPullAndPush,
runCreatePr, runCreatePr,
runMergeBranch, runMergeBranch,
runMergeFromBase, runMergeFromBase,
@@ -349,6 +359,17 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}); });
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled and pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull and push");
});
}, [cwd, runPullAndPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => { const handleCreatePr = useCallback(() => {
void persistShipDefault("pr"); void persistShipDefault("pr");
void runCreatePr({ serverId, cwd }) void runCreatePr({ serverId, cwd })
@@ -449,6 +470,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus); const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus);
const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus); const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus);
const pushDisabled = isActionDisabled(actionsDisabled, pushStatus); const pushDisabled = isActionDisabled(actionsDisabled, pushStatus);
const pullAndPushDisabled = isActionDisabled(actionsDisabled, pullAndPushStatus);
const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus); const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus);
const branchLabel = resolveBranchLabel({ const branchLabel = resolveBranchLabel({
@@ -502,6 +524,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.push, icon: icons.push,
handler: handlePush, handler: handlePush,
}, },
"pull-and-push": {
disabled: pullAndPushDisabled,
status: pullAndPushStatus,
icon: icons.pullAndPush,
handler: handlePullAndPush,
},
pr: { pr: {
disabled: prDisabled, disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus, status: hasPullRequest ? "idle" : prCreateStatus,
@@ -547,6 +575,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitDisabled, commitDisabled,
pullDisabled, pullDisabled,
pushDisabled, pushDisabled,
pullAndPushDisabled,
prDisabled, prDisabled,
mergeDisabled, mergeDisabled,
mergeFromBaseDisabled, mergeFromBaseDisabled,
@@ -554,6 +583,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitStatus, commitStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
pullAndPushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
@@ -561,6 +591,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handleCommit, handleCommit,
handlePull, handlePull,
handlePush, handlePush,
handlePullAndPush,
handlePrAction, handlePrAction,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
@@ -1,6 +1,7 @@
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { import {
Archive, Archive,
ArrowDownUp,
Download, Download,
GitCommitHorizontal, GitCommitHorizontal,
GitMerge, GitMerge,
@@ -21,6 +22,7 @@ interface WorkspaceGitActionsProps {
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download); const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload); const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge); const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedRefreshCcw = withUnistyles(RefreshCcw);
@@ -34,6 +36,7 @@ const ICONS = {
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />, commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />, pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />, push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />, merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
@@ -93,6 +93,72 @@ describe("checkout-git-actions-store", () => {
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle"); 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 () => { it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -17,6 +17,7 @@ export type CheckoutGitAsyncActionId =
| "commit" | "commit"
| "pull" | "pull"
| "push" | "push"
| "pull-and-push"
| "create-pr" | "create-pr"
| "merge-branch" | "merge-branch"
| "merge-from-base" | "merge-from-base"
@@ -234,6 +235,7 @@ interface CheckoutGitActionsStoreState {
commit: (params: { serverId: string; cwd: string }) => Promise<void>; commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>; pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (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>; createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (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 }) => { createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,