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

View File

@@ -36,6 +36,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"pull-and-push": {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
@@ -65,7 +70,7 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
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", () => {
@@ -149,6 +154,7 @@ describe("git-actions-policy", () => {
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
@@ -158,6 +164,48 @@ describe("git-actions-policy", () => {
).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", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));

View File

@@ -6,6 +6,7 @@ export type GitActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "pr"
| "merge-branch"
| "merge-from-base"
@@ -56,7 +57,7 @@ export interface BuildGitActionsInput {
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"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
@@ -101,6 +102,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
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("merge-branch", {
@@ -265,6 +280,19 @@ function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefi
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 {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";

View File

@@ -31,6 +31,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
Archive,
ArrowDownUp,
ChevronDown,
Columns2,
Download,
@@ -1071,6 +1072,7 @@ interface GitActionRunners {
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
runPull: (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>;
runMergeBranch: (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;
handlePull: () => void;
handlePush: () => void;
handlePullAndPush: () => void;
handleCreatePr: () => void;
handleMergeBranch: () => void;
handleMergeFromBase: () => void;
@@ -1154,6 +1157,18 @@ function useGitActionHandlers({
});
}, [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(() => {
void persistShipDefault("pr");
void runners
@@ -1233,6 +1248,7 @@ function useGitActionHandlers({
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
@@ -1350,6 +1366,7 @@ function computeDisabledStates(
commitDisabled: actionsDisabled || statuses.commitStatus === pending,
pullDisabled: actionsDisabled || statuses.pullStatus === pending,
pushDisabled: actionsDisabled || statuses.pushStatus === pending,
pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending,
prDisabled: actionsDisabled || statuses.prCreateStatus === pending,
mergeDisabled: actionsDisabled || statuses.mergeStatus === pending,
mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending,
@@ -1393,6 +1410,7 @@ interface GitActionsStatusInputs {
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
@@ -1403,6 +1421,7 @@ interface GitActionsDisabledInputs {
commitDisabled: boolean;
pullDisabled: boolean;
pushDisabled: boolean;
pullAndPushDisabled: boolean;
prDisabled: boolean;
mergeDisabled: boolean;
mergeFromBaseDisabled: boolean;
@@ -1462,6 +1481,12 @@ function buildGitActionsForPane({
icon: <Upload size={16} color={iconColor} />,
handler: handlers.handlePush,
},
"pull-and-push": {
disabled: disabled.pullAndPushDisabled,
status: statuses.pullAndPushStatus,
icon: <ArrowDownUp size={16} color={iconColor} />,
handler: handlers.handlePullAndPush,
},
pr: {
disabled: disabled.prDisabled,
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
@@ -1879,6 +1904,9 @@ export function GitDiffPane({
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
@@ -1895,6 +1923,7 @@ export function GitDiffPane({
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
@@ -1938,6 +1967,7 @@ export function GitDiffPane({
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
@@ -1950,6 +1980,7 @@ export function GitDiffPane({
runMergeBranch,
runMergeFromBase,
runPull,
runPullAndPush,
runPush,
],
);
@@ -1958,6 +1989,7 @@ export function GitDiffPane({
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
@@ -2081,6 +2113,7 @@ export function GitDiffPane({
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
@@ -2092,6 +2125,7 @@ export function GitDiffPane({
mergeFromBaseStatus,
mergeStatus,
prCreateStatus,
pullAndPushStatus,
pullStatus,
pushStatus,
],
@@ -2155,6 +2189,7 @@ export function GitDiffPane({
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
@@ -2169,6 +2204,7 @@ export function GitDiffPane({
handleMergeFromBase,
handlePr,
handlePull,
handlePullAndPush,
handlePush,
],
);

View File

@@ -54,6 +54,7 @@ function useGitActionStatuses(
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
@@ -68,6 +69,9 @@ function useGitActionStatuses(
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
@@ -84,6 +88,7 @@ function useGitActionStatuses(
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
@@ -180,6 +185,7 @@ function useGitActionRunners() {
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
@@ -188,6 +194,7 @@ function useGitActionRunners() {
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
@@ -202,6 +209,7 @@ interface UseGitActionsInput {
commit: ReactElement;
pull: ReactElement;
push: ReactElement;
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
merge: ReactElement;
@@ -284,6 +292,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
@@ -294,6 +303,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
@@ -349,6 +359,17 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [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(() => {
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
@@ -449,6 +470,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus);
const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus);
const pushDisabled = isActionDisabled(actionsDisabled, pushStatus);
const pullAndPushDisabled = isActionDisabled(actionsDisabled, pullAndPushStatus);
const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus);
const branchLabel = resolveBranchLabel({
@@ -502,6 +524,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.push,
handler: handlePush,
},
"pull-and-push": {
disabled: pullAndPushDisabled,
status: pullAndPushStatus,
icon: icons.pullAndPush,
handler: handlePullAndPush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
@@ -547,6 +575,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitDisabled,
pullDisabled,
pushDisabled,
pullAndPushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
@@ -554,6 +583,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
@@ -561,6 +591,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergeBranch,
handleMergeFromBase,

View File

@@ -1,6 +1,7 @@
import { withUnistyles } from "react-native-unistyles";
import {
Archive,
ArrowDownUp,
Download,
GitCommitHorizontal,
GitMerge,
@@ -21,6 +22,7 @@ interface WorkspaceGitActionsProps {
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
@@ -34,6 +36,7 @@ const ICONS = {
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,

View File

@@ -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();

View File

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