From 497159ada888fcf1cec5fc67ff13185f947b507c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 20:13:59 +0700 Subject: [PATCH] Add pull action and restructure git actions policy --- .../src/components/git-actions-policy.test.ts | 145 +++++++++-------- .../app/src/components/git-actions-policy.ts | 147 ++++++++++++------ .../components/git-actions-split-button.tsx | 3 +- packages/app/src/components/git-diff-pane.tsx | 28 ++++ packages/app/src/hooks/use-git-actions.ts | 28 ++++ .../workspace/workspace-git-actions.tsx | 10 +- .../src/stores/checkout-git-actions-store.ts | 17 ++ .../server/src/client/daemon-client.test.ts | 55 +++++++ packages/server/src/client/daemon-client.ts | 14 ++ packages/server/src/server/session.ts | 36 +++++ packages/server/src/shared/messages.ts | 20 +++ .../server/src/utils/checkout-git.test.ts | 23 +++ packages/server/src/utils/checkout-git.ts | 13 ++ 13 files changed, 422 insertions(+), 117 deletions(-) diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index e1dc936f5..1ee48e87f 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -15,6 +15,7 @@ function createInput(overrides: Partial = {}): BuildGitAct baseRefAvailable: true, baseRefLabel: "main", aheadCount: 0, + behindBaseCount: 0, aheadOfOrigin: 0, behindOfOrigin: 0, shouldPromoteArchive: false, @@ -25,6 +26,11 @@ function createInput(overrides: Partial = {}): BuildGitAct status: "idle", handler: () => undefined, }, + pull: { + disabled: false, + status: "idle", + handler: () => undefined, + }, push: { disabled: false, status: "idle", @@ -56,87 +62,88 @@ function createInput(overrides: Partial = {}): BuildGitAct } describe("git-actions-policy", () => { - it("keeps the secondary menu order stable while the primary action changes", () => { - const noPrActions = buildGitActions(createInput()); - const withPrActions = buildGitActions( - createInput({ - hasRemote: true, - hasPullRequest: true, - pullRequestUrl: "https://example.com/pr/123", - aheadCount: 3, - aheadOfOrigin: 2, - shipDefault: "pr", - }), - ); + it("shows only remote sync actions on the base branch", () => { + const actions = buildGitActions(createInput({ hasRemote: true })); - expect(noPrActions.primary).toBeNull(); - expect(withPrActions.primary?.id).toBe("push"); - expect(noPrActions.secondary.map((action) => action.id)).toEqual([ - "merge-branch", - "pr", - "merge-from-base", - "push", - ]); - expect(withPrActions.secondary.map((action) => action.id)).toEqual([ - "merge-branch", - "pr", - "merge-from-base", - "push", - ]); + expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]); }); - it("disables hidden-before actions with explanations instead", () => { - const actions = buildGitActions(createInput()); - const actionById = new Map(actions.secondary.map((action) => [action.id, action])); - - expect(actionById.get("push")).toMatchObject({ - disabled: true, - description: "No remote configured", - }); - expect(actionById.get("pr")).toMatchObject({ - label: "Create PR", - disabled: true, - description: "Branch has no commits ahead of main", - }); - expect(actionById.get("merge-branch")).toMatchObject({ - disabled: true, - description: "No commits to merge into main", - }); - expect(actionById.get("merge-from-base")).toMatchObject({ - disabled: true, - description: "No remote configured", - }); - expect(actionById.has("archive-worktree")).toBe(false); - }); - - it("keeps the current primary action visible in the menu", () => { + it("prioritizes pull when the branch is behind origin", () => { const actions = buildGitActions( createInput({ hasRemote: true, + behindOfOrigin: 2, + }), + ); + + expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" }); + }); + + it("disables push with a short pull-first message when the branch diverged", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + aheadOfOrigin: 1, + behindOfOrigin: 1, + }), + ); + const pushAction = actions.secondary.find((action) => action.id === "push"); + + expect(pushAction).toMatchObject({ + disabled: true, + description: "Pull first", + }); + }); + + it("shows update-from-base only on feature branches that are behind the base branch", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + behindBaseCount: 3, + }), + ); + const updateAction = actions.secondary.find((action) => action.id === "merge-from-base"); + + expect(updateAction).toMatchObject({ + label: "Update from main", + disabled: false, + }); + }); + + it("keeps update-from-base off the base branch entirely", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + behindOfOrigin: 2, + }), + ); + + expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false); + }); + + it("keeps feature branch actions available off the base branch", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + behindBaseCount: 1, hasPullRequest: true, pullRequestUrl: "https://example.com/pr/456", }), ); - expect(actions.primary?.id).toBe("pr"); - expect( - actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), - ).toBe(true); - }); - - it("disables sync on the base branch when already up to date", () => { - const actions = buildGitActions( - createInput({ - hasRemote: true, - }), + expect(actions.secondary.map((action) => action.id)).toEqual([ + "pull", + "push", + "merge-from-base", + "merge-branch", + "pr", + ]); + expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe( + true, ); - const syncAction = actions.secondary.find((action) => action.id === "merge-from-base"); - - expect(syncAction).toMatchObject({ - label: "Sync", - disabled: true, - description: "Already up to date", - }); }); it("only shows archive worktree for paseo worktrees", () => { diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index 768cc3a90..4220e8f96 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu"; export type GitActionId = | "commit" + | "pull" | "push" | "pr" | "merge-branch" @@ -47,6 +48,7 @@ export interface BuildGitActionsInput { baseRefAvailable: boolean; baseRefLabel: string; aheadCount: number; + behindBaseCount: number; aheadOfOrigin: number; behindOfOrigin: number; shouldPromoteArchive: boolean; @@ -54,7 +56,8 @@ export interface BuildGitActionsInput { runtime: Record; } -const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"]; +const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"]; +const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"]; export function buildGitActions(input: BuildGitActionsInput): GitActions { if (!input.isGit) { @@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { handler: input.runtime.commit.handler, }); + allActions.set("pull", { + id: "pull", + label: "Pull", + pendingLabel: "Pulling...", + successLabel: "Pulled", + disabled: input.runtime.pull.disabled || !canPull(input), + status: input.runtime.pull.status, + description: getPullDescription(input), + icon: input.runtime.pull.icon, + handler: input.runtime.pull.handler, + }); + allActions.set("push", { id: "push", label: "Push", pendingLabel: "Pushing...", successLabel: "Pushed", - disabled: input.runtime.push.disabled || !input.hasRemote, + disabled: input.runtime.push.disabled || !canPush(input), status: input.runtime.push.status, - description: input.hasRemote ? undefined : "No remote configured", + description: getPushDescription(input), icon: input.runtime.push.icon, handler: input.runtime.push.handler, }); @@ -93,11 +108,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: `Merge into ${input.baseRefLabel}`, pendingLabel: "Merging...", successLabel: "Merged", - disabled: - input.runtime["merge-branch"].disabled || - !input.baseRefAvailable || - input.hasUncommittedChanges || - input.aheadCount === 0, + disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input), status: input.runtime["merge-branch"].status, description: getMergeBranchDescription(input), icon: input.runtime["merge-branch"].icon, @@ -106,7 +117,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("merge-from-base", { id: "merge-from-base", - label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`, + label: `Update from ${input.baseRefLabel}`, pendingLabel: "Updating...", successLabel: "Updated", disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input), @@ -123,19 +134,27 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { successLabel: "Archived", disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, status: input.runtime["archive-worktree"].status, - description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees", + description: input.isPaseoOwnedWorktree ? undefined : "Only for worktrees", icon: input.runtime["archive-worktree"].icon, handler: input.runtime["archive-worktree"].handler, }); const primaryActionId = getPrimaryActionId(input); const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; - const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!); + + const secondaryIds = [...REMOTE_ACTION_IDS]; + if (!input.isOnBaseBranch) { + secondaryIds.push(...FEATURE_ACTION_IDS); + } if (input.isPaseoOwnedWorktree) { - secondary.push(allActions.get("archive-worktree")!); + secondaryIds.push("archive-worktree"); } - return { primary, secondary, menu: [] }; + return { + primary, + secondary: secondaryIds.map((id) => allActions.get(id)!), + menu: [], + }; } function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { @@ -145,20 +164,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { if (input.hasUncommittedChanges) { return "commit"; } - if (input.aheadOfOrigin > 0 && input.hasRemote) { + if (canPull(input)) { + return "pull"; + } + if (canPush(input)) { return "push"; } + if (!input.isOnBaseBranch && canMergeFromBase(input)) { + return "merge-from-base"; + } if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) { return "pr"; } - if ( - input.isOnBaseBranch && - input.hasRemote && - (input.aheadOfOrigin > 0 || input.behindOfOrigin > 0) - ) { - return "merge-from-base"; - } - if (input.aheadCount > 0) { + if (!input.isOnBaseBranch && input.aheadCount > 0) { return input.shipDefault === "merge" ? "merge-branch" : "pr"; } return null; @@ -173,7 +191,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { successLabel: "View PR", disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, status: input.runtime.pr.status, - description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable", + description: input.githubFeaturesEnabled ? undefined : "GitHub unavailable", icon: input.runtime.pr.icon, handler: input.runtime.pr.handler, }; @@ -192,57 +210,94 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { }; } +function canPull(input: BuildGitActionsInput): boolean { + return ( + input.hasRemote && + !input.hasUncommittedChanges && + input.behindOfOrigin > 0 + ); +} + +function canPush(input: BuildGitActionsInput): boolean { + return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0; +} + +function canMergeBranch(input: BuildGitActionsInput): boolean { + return ( + !input.isOnBaseBranch && + input.baseRefAvailable && + !input.hasUncommittedChanges && + input.aheadCount > 0 + ); +} + function canMergeFromBase(input: BuildGitActionsInput): boolean { - if (!input.baseRefAvailable || input.hasUncommittedChanges) { - return false; - } - if (!input.isOnBaseBranch) { - return true; - } + return ( + !input.isOnBaseBranch && + input.baseRefAvailable && + !input.hasUncommittedChanges && + input.behindBaseCount > 0 + ); +} + +function getPullDescription(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return false; + return "No remote"; } - return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0; + if (input.hasUncommittedChanges) { + return "Clean tree"; + } + if (input.behindOfOrigin === 0) { + return "Nothing to pull"; + } + return undefined; +} + +function getPushDescription(input: BuildGitActionsInput): string | undefined { + if (!input.hasRemote) { + return "No remote"; + } + if (input.behindOfOrigin > 0) { + return "Pull first"; + } + if (input.aheadOfOrigin === 0) { + return "Nothing to push"; + } + return undefined; } function getCreatePrDescription(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return "GitHub features unavailable"; + return "GitHub unavailable"; } if (input.aheadCount === 0) { - return `Branch has no commits ahead of ${input.baseRefLabel}`; + return "No new commits"; } return undefined; } function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Base ref unavailable"; + return "No base"; } if (input.hasUncommittedChanges) { - return "Requires clean working tree"; + return "Clean tree"; } if (input.aheadCount === 0) { - return `No commits to merge into ${input.baseRefLabel}`; + return "No new commits"; } return undefined; } function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Base ref unavailable"; + return "No base"; } if (input.hasUncommittedChanges) { - return "Requires clean working tree"; + return "Clean tree"; } - if (!input.isOnBaseBranch) { - return undefined; - } - if (!input.hasRemote) { - return "No remote configured"; - } - if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) { - return "Already up to date"; + if (input.behindBaseCount === 0) { + return "Up to date"; } return undefined; } diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index 3d45b68c0..f0a6bd802 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -73,7 +73,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps {gitActions.secondary.map((action, index) => { - const needsSeparator = action.id === "merge-from-base" || action.id === "push"; + const needsSeparator = + action.id === "merge-from-base" || action.id === "archive-worktree"; return ( {needsSeparator && index > 0 ? : null} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cbb8b4ec4..1ec8d0988 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -28,6 +28,7 @@ import { Archive, ChevronDown, Columns2, + Download, GitBranch, GitCommitHorizontal, GitMerge, @@ -897,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); + const pullStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "pull" }), + ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); @@ -914,6 +918,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); @@ -945,6 +950,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }); }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); + const handlePull = useCallback(() => { + void runPull({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pulled"); + }) + .catch((err) => { + toastActionError(err, "Failed to pull"); + }); + }, [cwd, runPull, serverId, toastActionError, toastActionSuccess]); + const handlePush = useCallback(() => { void runPush({ serverId, cwd }) .then(() => { @@ -1071,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi : "Unknown"; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { @@ -1096,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; + const pullDisabled = actionsDisabled || pullStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; @@ -1202,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, + behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, @@ -1213,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi icon: , handler: handleCommit, }, + pull: { + disabled: pullDisabled, + status: pullStatus, + icon: , + handler: handlePull, + }, push: { disabled: pushDisabled, status: pushStatus, @@ -1257,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi hasPullRequest, prStatus?.url, aheadCount, + behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, @@ -1267,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi baseRefLabel, shouldPromoteArchive, commitDisabled, + pullDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, + pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, + handlePull, handlePush, handleCreatePr, handleMergeBranch, diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 535cd3687..9d6bd3d1d 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -20,6 +20,7 @@ interface UseGitActionsInput { cwd: string; icons: { commit: ReactElement; + pull: ReactElement; push: ReactElement; viewPr: ReactElement; createPr: ReactElement; @@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); + const pullStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "pull" }), + ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); @@ -119,6 +123,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); @@ -151,6 +156,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); + const handlePull = useCallback(() => { + void runPull({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pulled"); + }) + .catch((err) => { + toastActionError(err, "Failed to pull"); + }); + }, [cwd, runPull, serverId, toastActionError, toastActionSuccess]); + const handlePush = useCallback(() => { void runPush({ serverId, cwd }) .then(() => { @@ -230,6 +245,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { @@ -249,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; + const pullDisabled = actionsDisabled || pullStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; @@ -276,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, + behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, @@ -287,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use icon: icons.commit, handler: handleCommit, }, + pull: { + disabled: pullDisabled, + status: pullStatus, + icon: icons.pull, + handler: handlePull, + }, push: { disabled: pushDisabled, status: pushStatus, @@ -331,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasPullRequest, prStatus?.url, aheadCount, + behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, @@ -341,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRefLabel, shouldPromoteArchive, commitDisabled, + pullDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, + pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, + handlePull, handlePush, handleCreatePr, handleMergeBranch, diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx index 9213243de..86fb15d9c 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx @@ -1,6 +1,13 @@ import { useMemo } from "react"; import { useUnistyles } from "react-native-unistyles"; -import { Archive, GitCommitHorizontal, GitMerge, RefreshCcw, Upload } from "lucide-react-native"; +import { + Archive, + Download, + GitCommitHorizontal, + GitMerge, + RefreshCcw, + Upload, +} from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { useGitActions } from "@/hooks/use-git-actions"; @@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps) const icons = useMemo( () => ({ commit: , + pull: , push: , viewPr: , createPr: , diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 44829373b..c5c5f1177 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -8,6 +8,7 @@ export type CheckoutGitActionStatus = "idle" | "pending" | "success"; export type CheckoutGitAsyncActionId = | "commit" + | "pull" | "push" | "create-pr" | "merge-branch" @@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState { }) => CheckoutGitActionStatus; commit: (params: { serverId: string; cwd: string }) => Promise; + pull: (params: { serverId: string; cwd: string }) => Promise; push: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; @@ -223,6 +225,21 @@ export const useCheckoutGitActionsStore = create() }); }, + pull: async ({ serverId, cwd }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "pull", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutPull(cwd); + if (payload.error) { + throw new Error(payload.error.message); + } + }, + }); + }, + push: async ({ serverId, cwd }) => { await runCheckoutAction({ serverId, diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 144032ed8..2d15251a3 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -780,6 +780,61 @@ describe("DaemonClient", () => { }); }); + test("requests checkout pull via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.checkoutPull("/tmp/project", "req-pull"); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout_pull_request"; + cwd: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout_pull_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.requestId).toBe("req-pull"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout_pull_response", + payload: { + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }); + }); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 0ceacf94f..fdee4a5b8 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -25,6 +25,7 @@ import type { CheckoutCommitResponse, CheckoutMergeResponse, CheckoutMergeFromBaseResponse, + CheckoutPullResponse, CheckoutPushResponse, CheckoutPrCreateResponse, CheckoutPrStatusResponse, @@ -218,6 +219,7 @@ type CheckoutDiffPayload = Omit; type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; +type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; @@ -2339,6 +2341,18 @@ export class DaemonClient { }); } + async checkoutPull(cwd: string, requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout_pull_request", + cwd, + }, + responseType: "checkout_pull_response", + timeout: 60000, + }); + } + async checkoutPush(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8e50221c9..8d9807bfa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -157,6 +157,7 @@ import { commitChanges, mergeToBase, mergeFromBase, + pullCurrentBranch, pushCurrentBranch, createPullRequest, getPullRequestStatus, @@ -1767,6 +1768,10 @@ export class Session { await this.handleCheckoutMergeFromBaseRequest(msg); break; + case "checkout_pull_request": + await this.handleCheckoutPullRequest(msg); + break; + case "checkout_push_request": await this.handleCheckoutPushRequest(msg); break; @@ -4729,6 +4734,37 @@ export class Session { } } + private async handleCheckoutPullRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + await pullCurrentBranch(cwd); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + + this.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPushRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index bc207abf7..886d13941 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1029,6 +1029,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutPullRequestSchema = z.object({ + type: z.literal("checkout_pull_request"), + cwd: z.string(), + requestId: z.string(), +}); + export const CheckoutPushRequestSchema = z.object({ type: z.literal("checkout_push_request"), cwd: z.string(), @@ -1420,6 +1426,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutCommitRequestSchema, CheckoutMergeRequestSchema, CheckoutMergeFromBaseRequestSchema, + CheckoutPullRequestSchema, CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, @@ -2186,6 +2193,16 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({ }), }); +export const CheckoutPullResponseSchema = z.object({ + type: z.literal("checkout_pull_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPushResponseSchema = z.object({ type: z.literal("checkout_push_response"), payload: z.object({ @@ -2658,6 +2675,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutCommitResponseSchema, CheckoutMergeResponseSchema, CheckoutMergeFromBaseResponseSchema, + CheckoutPullResponseSchema, CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, @@ -2874,6 +2892,8 @@ export type CheckoutMergeRequest = z.infer; export type CheckoutMergeResponse = z.infer; export type CheckoutMergeFromBaseRequest = z.infer; export type CheckoutMergeFromBaseResponse = z.infer; +export type CheckoutPullRequest = z.infer; +export type CheckoutPullResponse = z.infer; export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 40a8ebbe3..0d58ddb09 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -28,6 +28,7 @@ import { MergeConflictError, MergeFromBaseConflictError, NotGitRepoError, + pullCurrentBranch, pushCurrentBranch, resolveRepositoryDefaultBranch, parseWorktreeList, @@ -534,6 +535,28 @@ const x = 1; expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); }); + it("pulls the current branch from origin", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + writeFileSync(join(otherClone, "pulled.txt"), "remote\n"); + execSync("git add pulled.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone }); + const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim(); + execSync("git push", { cwd: otherClone }); + + await pullCurrentBranch(repoDir); + + execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir }); + expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n"); + }); + it("pushes the current branch to origin", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ccfb38bde..ef246e32f 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1757,6 +1757,19 @@ export async function mergeFromBase( } } +export async function pullCurrentBranch(cwd: string): Promise { + await requireGitRepo(cwd); + const currentBranch = await getCurrentBranch(cwd); + if (!currentBranch || currentBranch === "HEAD") { + throw new Error("Unable to determine current branch for pull"); + } + const hasRemote = await hasOriginRemote(cwd); + if (!hasRemote) { + throw new Error("Remote 'origin' is not configured."); + } + await execAsync("git pull", { cwd }); +} + export async function pushCurrentBranch(cwd: string): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd);