Add pull action and restructure git actions policy

This commit is contained in:
Mohamed Boudra
2026-04-10 20:13:59 +07:00
parent e22fb51975
commit 497159ada8
13 changed files with 422 additions and 117 deletions
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): 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<BuildGitActionsInput> = {}): 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", () => {
+101 -46
View File
@@ -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<GitActionId, GitActionRuntimeState>;
}
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;
}
@@ -73,7 +73,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{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 (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
@@ -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: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
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,
+28
View File
@@ -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,
@@ -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: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
@@ -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<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (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>;
@@ -223,6 +225,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
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,
@@ -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();
@@ -25,6 +25,7 @@ import type {
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
CheckoutPullResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
@@ -218,6 +219,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
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<CheckoutPullPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_pull_request",
cwd,
},
responseType: "checkout_pull_response",
timeout: 60000,
});
}
async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
+36
View File
@@ -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<SessionInboundMessage, { type: "checkout_pull_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> {
+20
View File
@@ -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<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>;
export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
@@ -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}`);
+13
View File
@@ -1757,6 +1757,19 @@ export async function mergeFromBase(
}
}
export async function pullCurrentBranch(cwd: string): Promise<void> {
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<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);