diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index 93777d63a..41b76c1b2 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -36,6 +36,11 @@ function createInput(overrides: Partial = {}): 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 })); diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index 30e7605f4..e563806ba 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -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; } -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"; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index dfa4e6254..cf9198e0d 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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; runPull: (args: { serverId: string; cwd: string }) => Promise; runPush: (args: { serverId: string; cwd: string }) => Promise; + runPullAndPush: (args: { serverId: string; cwd: string }) => Promise; runCreatePr: (args: { serverId: string; cwd: string }) => Promise; runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; @@ -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: , handler: handlers.handlePush, }, + "pull-and-push": { + disabled: disabled.pullAndPushDisabled, + status: statuses.pullAndPushStatus, + icon: , + 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, ], ); diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 2016a971e..7ae1a6d7f 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -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, diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx index dc7e8179b..88546041c 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx @@ -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: , pull: , push: , + pullAndPush: , viewPr: , createPr: , merge: , diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/stores/checkout-git-actions-store.test.ts index 2c85aa30b..bf5f9e6db 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/stores/checkout-git-actions-store.test.ts @@ -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(); diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 70d78e2fc..11a6047c0 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -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; pull: (params: { serverId: string; cwd: string }) => Promise; push: (params: { serverId: string; cwd: string }) => Promise; + pullAndPush: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; @@ -347,6 +349,25 @@ export const useCheckoutGitActionsStore = create() }); }, + 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,