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, baseRefAvailable: true,
baseRefLabel: "main", baseRefLabel: "main",
aheadCount: 0, aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0, aheadOfOrigin: 0,
behindOfOrigin: 0, behindOfOrigin: 0,
shouldPromoteArchive: false, shouldPromoteArchive: false,
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle", status: "idle",
handler: () => undefined, handler: () => undefined,
}, },
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: { push: {
disabled: false, disabled: false,
status: "idle", status: "idle",
@@ -56,87 +62,88 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
} }
describe("git-actions-policy", () => { describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => { it("shows only remote sync actions on the base branch", () => {
const noPrActions = buildGitActions(createInput()); const actions = buildGitActions(createInput({ hasRemote: true }));
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
}),
);
expect(noPrActions.primary).toBeNull(); expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
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",
]);
}); });
it("disables hidden-before actions with explanations instead", () => { it("prioritizes pull when the branch is behind origin", () => {
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", () => {
const actions = buildGitActions( const actions = buildGitActions(
createInput({ createInput({
hasRemote: true, 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, hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
}), }),
); );
expect(actions.primary?.id).toBe("pr"); expect(actions.secondary.map((action) => action.id)).toEqual([
expect( "pull",
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), "push",
).toBe(true); "merge-from-base",
}); "merge-branch",
"pr",
it("disables sync on the base branch when already up to date", () => { ]);
const actions = buildGitActions( expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(
createInput({ true,
hasRemote: 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", () => { 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 = export type GitActionId =
| "commit" | "commit"
| "pull"
| "push" | "push"
| "pr" | "pr"
| "merge-branch" | "merge-branch"
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
baseRefAvailable: boolean; baseRefAvailable: boolean;
baseRefLabel: string; baseRefLabel: string;
aheadCount: number; aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number; aheadOfOrigin: number;
behindOfOrigin: number; behindOfOrigin: number;
shouldPromoteArchive: boolean; shouldPromoteArchive: boolean;
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>; 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 { export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) { if (!input.isGit) {
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.commit.handler, 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", { allActions.set("push", {
id: "push", id: "push",
label: "Push", label: "Push",
pendingLabel: "Pushing...", pendingLabel: "Pushing...",
successLabel: "Pushed", successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote, disabled: input.runtime.push.disabled || !canPush(input),
status: input.runtime.push.status, status: input.runtime.push.status,
description: input.hasRemote ? undefined : "No remote configured", description: getPushDescription(input),
icon: input.runtime.push.icon, icon: input.runtime.push.icon,
handler: input.runtime.push.handler, handler: input.runtime.push.handler,
}); });
@@ -93,11 +108,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`, label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...", pendingLabel: "Merging...",
successLabel: "Merged", successLabel: "Merged",
disabled: disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input),
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status, status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input), description: getMergeBranchDescription(input),
icon: input.runtime["merge-branch"].icon, icon: input.runtime["merge-branch"].icon,
@@ -106,7 +117,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("merge-from-base", { allActions.set("merge-from-base", {
id: "merge-from-base", id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`, label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...", pendingLabel: "Updating...",
successLabel: "Updated", successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input), disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
@@ -123,19 +134,27 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
successLabel: "Archived", successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
status: input.runtime["archive-worktree"].status, 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, icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler, handler: input.runtime["archive-worktree"].handler,
}); });
const primaryActionId = getPrimaryActionId(input); const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; 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) { 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 { function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
@@ -145,20 +164,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.hasUncommittedChanges) { if (input.hasUncommittedChanges) {
return "commit"; return "commit";
} }
if (input.aheadOfOrigin > 0 && input.hasRemote) { if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push"; return "push";
} }
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) { if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr"; return "pr";
} }
if ( if (!input.isOnBaseBranch && input.aheadCount > 0) {
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr"; return input.shipDefault === "merge" ? "merge-branch" : "pr";
} }
return null; return null;
@@ -173,7 +191,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
successLabel: "View PR", successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
status: input.runtime.pr.status, status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable", description: input.githubFeaturesEnabled ? undefined : "GitHub unavailable",
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler, 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 { function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) { return (
return false; !input.isOnBaseBranch &&
} input.baseRefAvailable &&
if (!input.isOnBaseBranch) { !input.hasUncommittedChanges &&
return true; input.behindBaseCount > 0
} );
}
function getPullDescription(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) { 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 { function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) { if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable"; return "GitHub unavailable";
} }
if (input.aheadCount === 0) { if (input.aheadCount === 0) {
return `Branch has no commits ahead of ${input.baseRefLabel}`; return "No new commits";
} }
return undefined; return undefined;
} }
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "Base ref unavailable"; return "No base";
} }
if (input.hasUncommittedChanges) { if (input.hasUncommittedChanges) {
return "Requires clean working tree"; return "Clean tree";
} }
if (input.aheadCount === 0) { if (input.aheadCount === 0) {
return `No commits to merge into ${input.baseRefLabel}`; return "No new commits";
} }
return undefined; return undefined;
} }
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "Base ref unavailable"; return "No base";
} }
if (input.hasUncommittedChanges) { if (input.hasUncommittedChanges) {
return "Requires clean working tree"; return "Clean tree";
} }
if (!input.isOnBaseBranch) { if (input.behindBaseCount === 0) {
return undefined; return "Up to date";
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
} }
return undefined; return undefined;
} }
@@ -73,7 +73,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu"> <DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => { {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 ( return (
<View key={action.id}> <View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null} {needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
@@ -28,6 +28,7 @@ import {
Archive, Archive,
ChevronDown, ChevronDown,
Columns2, Columns2,
Download,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
GitMerge, GitMerge,
@@ -897,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitStatus = useCheckoutGitActionsStore((state) => const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }), state.getStatus({ serverId, cwd, actionId: "commit" }),
); );
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), 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 runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
@@ -945,6 +950,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}); });
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); }, [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(() => { const handlePush = useCallback(() => {
void runPush({ serverId, cwd }) void runPush({ serverId, cwd })
.then(() => { .then(() => {
@@ -1071,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
: "Unknown"; : "Unknown";
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => { const baseRefLabel = useMemo(() => {
@@ -1096,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
(postShipArchiveSuggested || isMergedPullRequest); (postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending"; const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -1202,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefAvailable: Boolean(baseRef), baseRefAvailable: Boolean(baseRef),
baseRefLabel, baseRefLabel,
aheadCount, aheadCount,
behindBaseCount,
aheadOfOrigin, aheadOfOrigin,
behindOfOrigin, behindOfOrigin,
shouldPromoteArchive, shouldPromoteArchive,
@@ -1213,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />, icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit, handler: handleCommit,
}, },
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
handler: handlePull,
},
push: { push: {
disabled: pushDisabled, disabled: pushDisabled,
status: pushStatus, status: pushStatus,
@@ -1257,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
hasPullRequest, hasPullRequest,
prStatus?.url, prStatus?.url,
aheadCount, aheadCount,
behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
githubFeaturesEnabled, githubFeaturesEnabled,
@@ -1267,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefLabel, baseRefLabel,
shouldPromoteArchive, shouldPromoteArchive,
commitDisabled, commitDisabled,
pullDisabled,
pushDisabled, pushDisabled,
prDisabled, prDisabled,
mergeDisabled, mergeDisabled,
mergeFromBaseDisabled, mergeFromBaseDisabled,
archiveDisabled, archiveDisabled,
commitStatus, commitStatus,
pullStatus,
pushStatus, pushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
handleCommit, handleCommit,
handlePull,
handlePush, handlePush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
+28
View File
@@ -20,6 +20,7 @@ interface UseGitActionsInput {
cwd: string; cwd: string;
icons: { icons: {
commit: ReactElement; commit: ReactElement;
pull: ReactElement;
push: ReactElement; push: ReactElement;
viewPr: ReactElement; viewPr: ReactElement;
createPr: ReactElement; createPr: ReactElement;
@@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const commitStatus = useCheckoutGitActionsStore((state) => const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }), state.getStatus({ serverId, cwd, actionId: "commit" }),
); );
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), 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 runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
@@ -151,6 +156,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}); });
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); }, [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(() => { const handlePush = useCallback(() => {
void runPush({ serverId, cwd }) void runPush({ serverId, cwd })
.then(() => { .then(() => {
@@ -230,6 +245,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Derived state // Derived state
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => { const baseRefLabel = useMemo(() => {
@@ -249,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
(postShipArchiveSuggested || isMergedPullRequest); (postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending"; const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -276,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefAvailable: Boolean(baseRef), baseRefAvailable: Boolean(baseRef),
baseRefLabel, baseRefLabel,
aheadCount, aheadCount,
behindBaseCount,
aheadOfOrigin, aheadOfOrigin,
behindOfOrigin, behindOfOrigin,
shouldPromoteArchive, shouldPromoteArchive,
@@ -287,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.commit, icon: icons.commit,
handler: handleCommit, handler: handleCommit,
}, },
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: { push: {
disabled: pushDisabled, disabled: pushDisabled,
status: pushStatus, status: pushStatus,
@@ -331,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasPullRequest, hasPullRequest,
prStatus?.url, prStatus?.url,
aheadCount, aheadCount,
behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
githubFeaturesEnabled, githubFeaturesEnabled,
@@ -341,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefLabel, baseRefLabel,
shouldPromoteArchive, shouldPromoteArchive,
commitDisabled, commitDisabled,
pullDisabled,
pushDisabled, pushDisabled,
prDisabled, prDisabled,
mergeDisabled, mergeDisabled,
mergeFromBaseDisabled, mergeFromBaseDisabled,
archiveDisabled, archiveDisabled,
commitStatus, commitStatus,
pullStatus,
pushStatus, pushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
handleCommit, handleCommit,
handlePull,
handlePush, handlePush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
@@ -1,6 +1,13 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useUnistyles } from "react-native-unistyles"; 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 { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions"; import { useGitActions } from "@/hooks/use-git-actions";
@@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps)
const icons = useMemo( const icons = useMemo(
() => ({ () => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />, commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />, push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />, viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <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 = export type CheckoutGitAsyncActionId =
| "commit" | "commit"
| "pull"
| "push" | "push"
| "create-pr" | "create-pr"
| "merge-branch" | "merge-branch"
@@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState {
}) => CheckoutGitActionStatus; }) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>; commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>; push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>; createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
@@ -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 }) => { push: async ({ serverId, cwd }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, 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 () => { test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
@@ -25,6 +25,7 @@ import type {
CheckoutCommitResponse, CheckoutCommitResponse,
CheckoutMergeResponse, CheckoutMergeResponse,
CheckoutMergeFromBaseResponse, CheckoutMergeFromBaseResponse,
CheckoutPullResponse,
CheckoutPushResponse, CheckoutPushResponse,
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
@@ -218,6 +219,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["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> { async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,
+36
View File
@@ -157,6 +157,7 @@ import {
commitChanges, commitChanges,
mergeToBase, mergeToBase,
mergeFromBase, mergeFromBase,
pullCurrentBranch,
pushCurrentBranch, pushCurrentBranch,
createPullRequest, createPullRequest,
getPullRequestStatus, getPullRequestStatus,
@@ -1767,6 +1768,10 @@ export class Session {
await this.handleCheckoutMergeFromBaseRequest(msg); await this.handleCheckoutMergeFromBaseRequest(msg);
break; break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request": case "checkout_push_request":
await this.handleCheckoutPushRequest(msg); await this.handleCheckoutPushRequest(msg);
break; 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( private async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> { ): Promise<void> {
+20
View File
@@ -1029,6 +1029,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({
requestId: z.string(), 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({ export const CheckoutPushRequestSchema = z.object({
type: z.literal("checkout_push_request"), type: z.literal("checkout_push_request"),
cwd: z.string(), cwd: z.string(),
@@ -1420,6 +1426,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitRequestSchema, CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema, CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema, CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema,
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema, 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({ export const CheckoutPushResponseSchema = z.object({
type: z.literal("checkout_push_response"), type: z.literal("checkout_push_response"),
payload: z.object({ payload: z.object({
@@ -2658,6 +2675,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitResponseSchema, CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema, CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema, CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema,
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
@@ -2874,6 +2892,8 @@ export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>; export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>; export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>; 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 CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>; export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>; export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
@@ -28,6 +28,7 @@ import {
MergeConflictError, MergeConflictError,
MergeFromBaseConflictError, MergeFromBaseConflictError,
NotGitRepoError, NotGitRepoError,
pullCurrentBranch,
pushCurrentBranch, pushCurrentBranch,
resolveRepositoryDefaultBranch, resolveRepositoryDefaultBranch,
parseWorktreeList, parseWorktreeList,
@@ -534,6 +535,28 @@ const x = 1;
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); 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 () => { it("pushes the current branch to origin", async () => {
const remoteDir = join(tempDir, "remote.git"); const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`); 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> { export async function pushCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd); const currentBranch = await getCurrentBranch(cwd);