feat(checkout): merge PR action with real-GitHub e2e test (#814)

* feat(checkout): merge PR action with real-GitHub e2e test

Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.

Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.

PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.

* Update PR status snapshot expectations

* refactor: collect git/checkout/PR feature into packages/app/src/git/

Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).

Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).

Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.

Net delta: 43 files, +219/-1212.

* fix(app): use GitHub icon for all merge-PR menu actions

* test: namespace temp GitHub repos under `paseotmp-` and centralize naming

Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.

- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
  `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
  server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
  and now requires callers to pass a `category` instead of an arbitrary
  `prefix`. Updated pr-pane and composer-attachments specs accordingly.
This commit is contained in:
Mohamed Boudra
2026-05-09 15:39:26 +08:00
committed by GitHub
parent c097678cdf
commit ed07452c80
63 changed files with 1204 additions and 907 deletions
@@ -82,7 +82,7 @@ test.describe("Composer attachments", () => {
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-issue-",
category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
@@ -114,7 +114,7 @@ test.describe("Composer attachments", () => {
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-pr-",
category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
+8 -3
View File
@@ -153,14 +153,19 @@ function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture
return { number: issueNumber, title: spec.title, url: issueUrl };
}
// Single namespace for temporary GitHub repos created by Paseo tests.
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
// (collides with real repos like `paseo`, `paseo-website`).
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
export async function createTempGithubRepo(options: {
prefix?: string;
category: string;
prs?: PrSpec[];
issues?: IssueSpec[];
}): Promise<GhRepoFixture> {
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
const { category, prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${prefix}${uniqueSuffix}`;
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/utils/pr-pane-data";
import { getStateLabel } from "@/git/pr-pane-data";
export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
+1 -1
View File
@@ -28,7 +28,7 @@ test.describe("PR pane", () => {
seedClient = await connectWorkspaceSetupClient();
repoFixture = await createTempGithubRepo({
prefix: "paseo-e2e-pr-",
category: "pr-pane",
prs: [
{ title: "Review selected start ref", state: "open" },
{ title: "Merged feature branch", state: "merged" },
@@ -13,7 +13,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "./pr-pane";
import { PrPane } from "@/git/pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
import {
usePanelStore,
@@ -24,7 +24,7 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
@@ -81,7 +81,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -95,7 +95,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -27,7 +27,7 @@ import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
@@ -19,7 +19,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
import type { GitAction, GitActions } from "@/git/policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -163,9 +163,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
action={action}
onSelect={handleActionSelect}
archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={
action.id === "merge-from-base" || action.id === "archive-worktree"
}
needsSeparator={action.startsGroup}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
@@ -9,7 +9,7 @@ import {
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
} from "@/git/actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
@@ -1,4 +1,5 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
@@ -27,6 +28,9 @@ export type CheckoutGitAsyncActionId =
| "push"
| "pull-and-push"
| "create-pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -259,6 +263,11 @@ interface CheckoutGitActionsStoreState {
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
@@ -405,6 +414,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
mergePr: async ({ serverId, cwd, method }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: `merge-pr-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrMerge(cwd, { method });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./diff-order";
function createFile(path: string, additions = 0) {
return {
@@ -25,7 +25,6 @@ import {
type ViewStyle,
type TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -45,20 +44,16 @@ import {
Upload,
WrapText,
} from "lucide-react-native";
import {
useCheckoutGitActionsStore,
type CheckoutGitActionStatus,
} from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
} from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll";
import { DiffScroll } from "@/components/diff-scroll";
import {
darkHighlightColors,
lightHighlightColors,
@@ -66,7 +61,7 @@ import {
} from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
@@ -83,16 +78,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import {
formatDiffContentText,
formatDiffGutterText,
@@ -120,11 +111,7 @@ import {
type InlineReviewActions,
} from "@/review";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
@@ -1274,16 +1261,6 @@ function getDiffContentLength(file: ParsedDiffFile): number {
return contentLength;
}
function computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string {
if (currentBranch && currentBranch !== "HEAD") {
return currentBranch;
}
if (notGit) {
return "Not a git repository";
}
return "Unknown";
}
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -1411,194 +1388,6 @@ function DiffBodyContent({
);
}
interface GitActionRunners {
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
runPull: (args: { serverId: string; cwd: string }) => Promise<void>;
runPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runPullAndPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>;
runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runArchiveWorktree: (args: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
interface GitActionHandlersDeps {
serverId: string;
cwd: string;
baseRef: string | undefined;
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
runners: GitActionRunners;
persistShipDefault: (next: "merge" | "pr") => Promise<void>;
toastError: (message: string) => void;
toastActionError: (error: unknown, fallback: string) => void;
toastActionSuccess: (message: string) => void;
onMergeBranchSuccess: () => void;
onArchiveSuccess: (targetWorkingDir: string) => void;
}
interface GitActionHandlers {
handleCommit: () => void;
handlePull: () => void;
handlePush: () => void;
handlePullAndPush: () => void;
handleCreatePr: () => void;
handleMergeBranch: () => void;
handleMergeFromBase: () => void;
handleArchiveWorktree: () => void;
}
function useGitActionHandlers({
serverId,
cwd,
baseRef,
status,
runners,
persistShipDefault,
toastError,
toastActionError,
toastActionSuccess,
onMergeBranchSuccess,
onArchiveSuccess,
}: GitActionHandlersDeps): GitActionHandlers {
const handleCommit = useCallback(() => {
void runners
.runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runners
.runPull({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
void runners
.runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runners
.runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled and pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull and push");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
void runners
.runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
return;
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toastError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
void runners
.runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
onMergeBranchSuccess();
toastActionSuccess("Merged");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge");
});
}, [
baseRef,
cwd,
onMergeBranchSuccess,
persistShipDefault,
runners,
serverId,
toastActionError,
toastActionSuccess,
toastError,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
toastError("Base ref unavailable");
return;
}
void runners
.runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runners, serverId, toastActionError, toastActionSuccess, toastError]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toastError("Worktree path unavailable");
return;
}
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runners
.runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
onArchiveSuccess(targetWorkingDir);
return;
})
.catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, onArchiveSuccess, runners, serverId, status, toastActionError, toastError]);
return {
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
};
}
interface DeriveStatusStateInputs {
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
isStatusLoading: boolean;
@@ -1642,35 +1431,6 @@ function deriveStatusState({
};
}
interface DerivedBranchState {
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
hasPullRequest: boolean;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isMergedPullRequest: boolean;
currentBranch: string | null | undefined;
}
function deriveBranchState(
gitStatus: DerivedStatusState["gitStatus"],
prStatus: { url?: string | null; isMerged?: boolean | null } | null | undefined,
): DerivedBranchState {
return {
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
hasPullRequest: Boolean(prStatus?.url),
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree: gitStatus?.isPaseoOwnedWorktree ?? false,
isMergedPullRequest: Boolean(prStatus?.isMerged),
currentBranch: gitStatus?.currentBranch,
};
}
function computeBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
@@ -1687,36 +1447,6 @@ function computeCommittedDiffDescription(
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}
function computeShouldPromoteArchive(
isPaseoOwnedWorktree: boolean,
hasUncommittedChanges: boolean,
postShipArchiveSuggested: boolean,
isMergedPullRequest: boolean,
): boolean {
return (
isPaseoOwnedWorktree &&
!hasUncommittedChanges &&
(postShipArchiveSuggested || isMergedPullRequest)
);
}
function computeDisabledStates(
actionsDisabled: boolean,
statuses: GitActionsStatusInputs,
): GitActionsDisabledInputs {
const pending = "pending";
return {
commitDisabled: actionsDisabled || statuses.commitStatus === pending,
pullDisabled: actionsDisabled || statuses.pullStatus === pending,
pushDisabled: actionsDisabled || statuses.pushStatus === pending,
pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending,
prDisabled: actionsDisabled || statuses.prCreateStatus === pending,
mergeDisabled: actionsDisabled || statuses.mergeStatus === pending,
mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending,
archiveDisabled: actionsDisabled || statuses.archiveStatus === pending,
};
}
function computePrErrorMessage(
githubFeaturesEnabled: boolean,
prPayloadError: { message?: string } | null | undefined,
@@ -1736,128 +1466,6 @@ function buildToggleButtonStyle(
];
}
function createPrHandler(
prStatus: { url?: string | null } | null | undefined,
handleCreatePr: () => void,
): () => void {
return () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
};
}
interface GitActionsStatusInputs {
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
archiveStatus: CheckoutGitActionStatus;
}
interface GitActionsDisabledInputs {
commitDisabled: boolean;
pullDisabled: boolean;
pushDisabled: boolean;
pullAndPushDisabled: boolean;
prDisabled: boolean;
mergeDisabled: boolean;
mergeFromBaseDisabled: boolean;
archiveDisabled: boolean;
}
interface BuildGitActionsParams {
policy: {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
};
statuses: GitActionsStatusInputs;
disabled: GitActionsDisabledInputs;
handlers: GitActionHandlers & { handlePr: () => void };
iconColor: string;
}
function buildGitActionsForPane({
policy,
statuses,
disabled,
handlers,
iconColor,
}: BuildGitActionsParams): GitActions {
return buildGitActions({
...policy,
runtime: {
commit: {
disabled: disabled.commitDisabled,
status: statuses.commitStatus,
icon: <GitCommitHorizontal size={16} color={iconColor} />,
handler: handlers.handleCommit,
},
pull: {
disabled: disabled.pullDisabled,
status: statuses.pullStatus,
icon: <Download size={16} color={iconColor} />,
handler: handlers.handlePull,
},
push: {
disabled: disabled.pushDisabled,
status: statuses.pushStatus,
icon: <Upload size={16} color={iconColor} />,
handler: handlers.handlePush,
},
"pull-and-push": {
disabled: disabled.pullAndPushDisabled,
status: statuses.pullAndPushStatus,
icon: <ArrowDownUp size={16} color={iconColor} />,
handler: handlers.handlePullAndPush,
},
pr: {
disabled: disabled.prDisabled,
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
icon: <GitHubIcon size={16} color={iconColor} />,
handler: handlers.handlePr,
},
"merge-branch": {
disabled: disabled.mergeDisabled,
status: statuses.mergeStatus,
icon: <GitMerge size={16} color={iconColor} />,
handler: handlers.handleMergeBranch,
},
"merge-from-base": {
disabled: disabled.mergeFromBaseDisabled,
status: statuses.mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={iconColor} />,
handler: handlers.handleMergeFromBase,
},
"archive-worktree": {
disabled: disabled.archiveDisabled,
status: statuses.archiveStatus,
icon: <Archive size={16} color={iconColor} />,
handler: handlers.handleArchiveWorktree,
},
},
});
}
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
return input.paneEnabled && input.isGit;
}
@@ -1870,13 +1478,10 @@ export function GitDiffPane({
enabled,
}: GitDiffPaneProps) {
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
@@ -1943,15 +1548,7 @@ export function GitDiffPane({
error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd });
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
const {
gitStatus,
isGit,
notGit,
statusErrorMessage,
baseRef,
hasUncommittedChanges,
actionsDisabled,
} = statusState;
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
@@ -2041,11 +1638,7 @@ export function GitDiffPane({
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const {
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
@@ -2079,45 +1672,6 @@ export function GitDiffPane({
const diffBodyLineHeight = theme.lineHeight.diff;
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
return;
}
let isActive = true;
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
}
return;
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey],
);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
@@ -2304,119 +1858,6 @@ export function GitDiffPane({
setDiffModeOverride(null);
}, [autoDiffMode]);
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" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
const handleMergeBranchSuccess = useCallback(() => {
setPostShipArchiveSuggested(true);
}, []);
const handleArchiveSuccess = useCallback(
(targetWorkingDir: string) => {
navigateToWorkspace(serverId, targetWorkingDir);
},
[serverId],
);
const toastError = useCallback(
(message: string) => {
toast.error(message);
},
[toast],
);
const runners = useMemo<GitActionRunners>(
() => ({
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
}),
[
runArchiveWorktree,
runCommit,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runPull,
runPullAndPush,
runPush,
],
);
const {
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
} = useGitActionHandlers({
serverId,
cwd,
baseRef,
status,
runners,
persistShipDefault,
toastError,
toastActionError,
toastActionSuccess,
onMergeBranchSuccess: handleMergeBranchSuccess,
onArchiveSuccess: handleArchiveSuccess,
});
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") {
@@ -2499,59 +1940,30 @@ export function GitDiffPane({
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const branchLabel = computeBranchLabel(gitStatus?.currentBranch, notGit);
const branchState = useMemo(() => deriveBranchState(gitStatus, prStatus), [gitStatus, prStatus]);
const {
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
hasPullRequest,
hasRemote,
isPaseoOwnedWorktree,
isMergedPullRequest,
currentBranch,
} = branchState;
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />,
pull: <Download size={16} color={iconColor} />,
push: <Upload size={16} color={iconColor} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
viewPr: <GitHubIcon size={16} color={iconColor} />,
createPr: <GitHubIcon size={16} color={iconColor} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
merge: <GitMerge size={16} color={iconColor} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
archive: <Archive size={16} color={iconColor} />,
}),
[iconColor],
);
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive = computeShouldPromoteArchive(
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
);
const gitActionsStatuses = useMemo<GitActionsStatusInputs>(
() => ({
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
}),
[
archiveStatus,
commitStatus,
mergeFromBaseStatus,
mergeStatus,
prCreateStatus,
pullAndPushStatus,
pullStatus,
pushStatus,
],
);
const gitActionsDisabled = useMemo<GitActionsDisabledInputs>(
() => computeDisabledStates(actionsDisabled, gitActionsStatuses),
[actionsDisabled, gitActionsStatuses],
);
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
@@ -2583,101 +1995,6 @@ export function GitDiffPane({
/>
);
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
// ==========================================================================
// Git Actions (Data-Oriented)
// ==========================================================================
// All possible actions are computed as data, then partitioned into:
// - primary: The main CTA button
// - secondary: Dropdown next to primary button
// - menu: Kebab overflow menu
// ==========================================================================
const handlePr = useMemo(
() => createPrHandler(prStatus, handleCreatePr),
[handleCreatePr, prStatus],
);
const gitActionsHandlers = useMemo(
() => ({
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
handlePr,
}),
[
handleArchiveWorktree,
handleCommit,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handlePr,
handlePull,
handlePullAndPush,
handlePush,
],
);
const gitActions: GitActions = useMemo(
() =>
buildGitActionsForPane({
policy: {
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
},
statuses: gitActionsStatuses,
disabled: gitActionsDisabled,
handlers: gitActionsHandlers,
iconColor: theme.colors.foregroundMuted,
}),
[
aheadCount,
aheadOfOrigin,
baseRef,
baseRefLabel,
behindBaseCount,
behindOfOrigin,
gitActionsDisabled,
gitActionsHandlers,
gitActionsStatuses,
githubFeaturesEnabled,
hasPullRequest,
hasRemote,
hasUncommittedChanges,
isGit,
isOnBaseBranch,
isPaseoOwnedWorktree,
prStatus?.url,
shipDefault,
shouldPromoteArchive,
theme.colors.foregroundMuted,
],
);
// Helper to get display label based on status
return (
<View style={styles.container}>
{!hideHeaderRow ? (
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll";
import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll";
describe("shouldAnchorHeaderBeforeCollapse", () => {
it("skips anchor when header is fully visible in viewport", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
@@ -8,6 +8,10 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -46,6 +50,21 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"merge-pr-squash": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-rebase": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
@@ -158,6 +177,9 @@ describe("git-actions-policy", () => {
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -213,4 +235,183 @@ describe("git-actions-policy", () => {
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
});
it("respects the ship preference when choosing between PR merge and local merge", () => {
const prPreferredActions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "pr",
}),
);
const localMergePreferredActions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "merge",
}),
);
expect(prPreferredActions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
expect(localMergePreferredActions.primary).toMatchObject({
id: "merge-branch",
label: "Merge locally",
});
expect(
localMergePreferredActions.secondary.some((action) => action.id === "merge-pr-squash"),
).toBe(true);
});
it("keeps the merge-pr actions in the feature branch menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
}),
);
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
});
it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions(
createInput({
isOnBaseBranch: false,
aheadCount: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "merge-branch");
expect(action).toMatchObject({ label: "Merge locally" });
});
it.each([
[
"draft",
{ pullRequestIsDraft: true },
"Merge PR isn't available because the pull request is still a draft",
],
[
"merged",
{ pullRequestIsMerged: true },
"Merge PR isn't available because the pull request is already merged",
],
[
"closed",
{ pullRequestState: "closed" as const },
"Merge PR isn't available because the pull request is closed",
],
[
"conflicting",
{ pullRequestMergeable: "CONFLICTING" as const },
"Merge PR isn't available because the pull request has conflicts",
],
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
...overrides,
}),
);
const mergePrActions = actions.secondary.filter((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
);
expect(mergePrActions).toHaveLength(3);
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
message,
message,
message,
]);
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
});
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
isPaseoOwnedWorktree: true,
}),
);
const allActions = [...actions.secondary];
const groupStarters = allActions
.filter((action) => action.startsGroup)
.map((action) => action.id);
const nonGroupStarters = allActions
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
"pull-and-push",
"merge-branch",
"pr",
"merge-pr-merge",
"merge-pr-rebase",
]);
});
});
@@ -1,6 +1,7 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
export type GitActionId =
| "commit"
@@ -8,6 +9,9 @@ export type GitActionId =
| "push"
| "pull-and-push"
| "pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -21,6 +25,8 @@ export interface GitAction {
status: ActionStatus;
unavailableMessage?: string;
icon?: ReactElement;
/** When true, a menu separator should be rendered before this item. */
startsGroup: boolean;
handler: () => void;
}
@@ -42,6 +48,10 @@ export interface BuildGitActionsInput {
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -58,7 +68,29 @@ export interface BuildGitActionsInput {
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
const FEATURE_ACTION_IDS: GitActionId[] = [
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
];
const MERGE_PR_METHODS: Record<
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
{ label: string; method: CheckoutPrMergeMethod }
> = {
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
};
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
if (state === "closed") return "closed";
return null;
}
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -75,6 +107,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
startsGroup: false,
handler: input.runtime.commit.handler,
});
@@ -87,6 +120,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
startsGroup: false,
handler: input.runtime.pull.handler,
});
@@ -99,6 +133,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
startsGroup: false,
handler: input.runtime.push.handler,
});
@@ -113,14 +148,19 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
startsGroup: false,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
label: "Merge locally",
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
@@ -129,6 +169,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
startsGroup: false,
handler: input.runtime["merge-branch"].handler,
});
@@ -143,6 +184,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
startsGroup: true,
handler: input.runtime["merge-from-base"].handler,
});
@@ -158,6 +200,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler,
});
@@ -195,11 +238,17 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (canMergePr(input) && input.shipDefault === "pr") {
return "merge-pr-squash";
}
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
return "pr";
}
return null;
}
@@ -218,6 +267,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
@@ -233,10 +283,32 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined
: getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
function buildMergePrAction(
input: BuildGitActionsInput,
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
): GitAction {
const runtime = input.runtime[id];
const config = MERGE_PR_METHODS[id];
const unavailableMessage = getMergePrUnavailableMessage(input);
return {
id,
label: config.label,
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
startsGroup: id === "merge-pr-squash",
handler: runtime.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
@@ -254,6 +326,22 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
);
}
function canMergePr(input: BuildGitActionsInput): boolean {
return (
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestState === "open" &&
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable === "MERGEABLE" &&
input.aheadCount > 0 &&
!input.hasUncommittedChanges &&
input.behindOfOrigin === 0 &&
input.aheadOfOrigin === 0 &&
!canMergeFromBase(input)
);
}
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
@@ -328,3 +416,34 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
}
return undefined;
}
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Merge PR isn't available right now because GitHub isn't connected";
}
if (!input.hasPullRequest) {
return "Merge PR isn't available because there isn't a pull request yet";
}
if (input.pullRequestIsDraft) {
return "Merge PR isn't available because the pull request is still a draft";
}
if (input.pullRequestIsMerged) {
return "Merge PR isn't available because the pull request is already merged";
}
if (input.pullRequestState === "closed") {
return "Merge PR isn't available because the pull request is closed";
}
if (input.pullRequestMergeable === "CONFLICTING") {
return "Merge PR isn't available because the pull request has conflicts";
}
return undefined;
}
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
return (
input.pullRequestIsDraft ||
input.pullRequestIsMerged ||
input.pullRequestState === "closed" ||
input.pullRequestMergeable === "CONFLICTING"
);
}
@@ -23,6 +23,7 @@ const baseStatus: CheckoutPrStatus = {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
};
@@ -16,14 +16,14 @@ import {
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
PrPaneCheck,
PrPaneData,
PrState,
} from "@/utils/pr-pane-data";
} from "@/git/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
@@ -1,24 +1,16 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
type CheckoutGitActionStatus,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
import {
type CheckoutStatusPayload,
useCheckoutStatusQuery,
} from "@/hooks/use-checkout-status-query";
import {
type CheckoutPrStatusPayload,
useCheckoutPrStatusQuery,
} from "@/hooks/use-checkout-pr-status-query";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -47,55 +39,6 @@ function formatBaseRefLabel(baseRef: string | undefined): string {
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
function useGitActionStatuses(
serverId: string,
cwd: string,
): {
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
archiveStatus: CheckoutGitActionStatus;
} {
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" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
return {
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
};
}
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | null;
interface DeriveGitActionsStateArgs {
@@ -181,27 +124,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
};
}
function useGitActionRunners() {
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
return {
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
};
}
interface UseGitActionsInput {
serverId: string;
cwd: string;
@@ -212,6 +134,9 @@ interface UseGitActionsInput {
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
@@ -287,27 +212,51 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
setPostShipArchiveSuggested(false);
}, [cwd]);
const {
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
} = useGitActionStatuses(serverId, cwd);
const commitStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
),
};
const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const {
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
} = useGitActionRunners();
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
const runPull = useCheckoutGitActionsStore((s) => s.pull);
const runPush = useCheckoutGitActionsStore((s) => s.push);
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
@@ -381,6 +330,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("PR merged");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge PR");
});
},
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toast.error("Base ref unavailable");
@@ -463,15 +428,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shouldPromoteArchive,
} = derived;
const commitDisabled = isActionDisabled(actionsDisabled, commitStatus);
const pullDisabled = isActionDisabled(actionsDisabled, pullStatus);
const prDisabled = isActionDisabled(actionsDisabled, prCreateStatus);
const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus);
const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus);
const pushDisabled = isActionDisabled(actionsDisabled, pushStatus);
const pullAndPushDisabled = isActionDisabled(actionsDisabled, pullAndPushStatus);
const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
@@ -492,6 +448,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -506,49 +466,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
disabled: isActionDisabled(actionsDisabled, commitStatus),
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
},
pull: {
disabled: pullDisabled,
disabled: isActionDisabled(actionsDisabled, pullStatus),
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: {
disabled: pushDisabled,
disabled: isActionDisabled(actionsDisabled, pushStatus),
status: pushStatus,
icon: icons.push,
handler: handlePush,
},
"pull-and-push": {
disabled: pullAndPushDisabled,
disabled: isActionDisabled(actionsDisabled, pullAndPushStatus),
status: pullAndPushStatus,
icon: icons.pullAndPush,
handler: handlePullAndPush,
},
pr: {
disabled: prDisabled,
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: icons.mergePrSquash,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: icons.mergePrMerge,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: icons.mergePrRebase,
handler: () => handleMergePr("rebase"),
},
"merge-branch": {
disabled: mergeDisabled,
disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus,
icon: icons.merge,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
disabled: isActionDisabled(actionsDisabled, mergeFromBaseStatus),
status: mergeFromBaseStatus,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorktree,
@@ -560,6 +538,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
@@ -571,19 +553,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pullDisabled,
pushDisabled,
pullAndPushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
@@ -592,6 +570,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
@@ -2,7 +2,7 @@ import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
function checkoutDiffQueryKey(
serverId: string,
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -9,8 +9,8 @@ import {
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";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceGitActionsProps {
@@ -39,6 +39,9 @@ const ICONS = {
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/stores/checkout-git-actions-store";
import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {
@@ -17,7 +17,7 @@ import {
usePrPaneData,
type UsePrPaneDataResult,
} from "./use-pr-pane-data";
import { useWorkspacePrHint } from "./use-checkout-pr-status-query";
import { useWorkspacePrHint } from "@/git/use-pr-status-query";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
@@ -67,6 +67,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
repoOwner: "getpaseo",
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@server/shared/messages";
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/test/window-local-storage";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
+1 -1
View File
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ComposerAttachment } from "@/attachments/types";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@server/shared/messages";
@@ -19,11 +19,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
@@ -54,7 +54,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -90,10 +90,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
+1 -1
View File
@@ -4,7 +4,7 @@ import {
buildSplitDiffRows,
buildUnifiedDiffLines,
} from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { ReviewableDiffTarget } from "./diff-layout";
function makeFile(
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
type ReviewSide = "old" | "new";
type ReviewableLineType = "add" | "remove" | "context";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import {
parseAgentKey,
resolveNewAgentWorkingDir,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import {
parseHostWorkspaceOpenIntentFromPathname,
parseHostAgentRouteFromPathname,
@@ -30,6 +30,8 @@ import type {
CheckoutPullResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrMergeResponse,
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
CheckoutSwitchBranchResponse,
@@ -263,6 +265,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
@@ -2744,6 +2747,23 @@ export class DaemonClient {
});
}
async checkoutPrMerge(
cwd: string,
input: { method: CheckoutPrMergeMethod },
requestId?: string,
): Promise<CheckoutPrMergePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_pr_merge_request",
cwd,
mergeMethod: input.method,
},
responseType: "checkout_pr_merge_response",
timeout: 60000,
});
}
async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -282,6 +282,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -0,0 +1,318 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execSync } from "child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { z } from "zod";
import {
createDaemonTestContext,
createTempGithubRepoName,
type DaemonTestContext,
} from "../test-utils/index.js";
import { createWorktree as createWorktreePrimitive } from "../../utils/worktree.js";
import type { PullRequestMergeable } from "../../shared/messages.js";
const GhPrViewSchema = z.object({
state: z.string(),
mergedAt: z.string().nullable(),
mergeCommit: z.object({ oid: z.string() }).nullable(),
});
type GhPrView = z.infer<typeof GhPrViewSchema>;
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
}
function hasGitHubCliAuth(): boolean {
try {
execSync("gh auth status -h github.com", { stdio: "pipe" });
return true;
} catch {
return false;
}
}
function hasGitHubCliDeleteRepoScope(): boolean {
try {
const status = execSync("gh auth status -h github.com 2>&1", { stdio: "pipe" }).toString();
const scopesLine = status.split(/\r?\n/).find((line) => /token[- ]scopes?:/i.test(line));
const hasDeleteRepoScope = scopesLine?.includes("delete_repo") ?? false;
if (!hasDeleteRepoScope) {
console.warn(
"Skipping checkout PR merge e2e: gh auth token is missing the delete_repo scope required to clean up temporary GitHub repos.",
);
}
return hasDeleteRepoScope;
} catch {
return false;
}
}
const hasRequiredGitHubCliAuth = hasGitHubCliAuth() && hasGitHubCliDeleteRepoScope();
const testWithGitHubCliAuth = hasRequiredGitHubCliAuth ? test : test.skip;
function initGitRepo(repoDir: string): void {
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'paseo-test@example.com'", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git config user.name 'Paseo Test'", {
cwd: repoDir,
stdio: "pipe",
});
writeFileSync(path.join(repoDir, "README.md"), "init\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd: repoDir,
stdio: "pipe",
});
}
function getGhLogin(): string {
return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim();
}
function createPrivateRepo(repoName: string): void {
execSync(`gh api -X POST user/repos -f name=${repoName} -f private=true`, {
stdio: "pipe",
});
}
function getGhToken(): string {
return execSync("gh auth token", { stdio: "pipe" }).toString().trim();
}
function formatCommandError(error: unknown): string {
if (error && typeof error === "object") {
const commandError = error as { stderr?: Buffer; stdout?: Buffer; message?: string };
const output = [commandError.stderr, commandError.stdout]
.map((buffer) => buffer?.toString().trim())
.filter(Boolean)
.join("\n");
return output || commandError.message || String(error);
}
return String(error);
}
function deleteRepoOrThrow(fullName: string | null): void {
if (!fullName) {
return;
}
try {
execSync(`gh repo delete ${fullName} --yes`, { stdio: "pipe" });
} catch (repoDeleteError) {
try {
execSync(`gh api -X DELETE repos/${fullName}`, { stdio: "pipe" });
} catch (apiDeleteError) {
throw new Error(
`Failed to delete temporary GitHub repo ${fullName}. Clean it up manually. ` +
`gh repo delete failed: ${formatCommandError(repoDeleteError)}. ` +
`gh api DELETE failed: ${formatCommandError(apiDeleteError)}`,
{ cause: apiDeleteError },
);
}
}
}
function readGhPrView(prNumber: number, repoFullName: string): GhPrView {
return GhPrViewSchema.parse(
JSON.parse(
execSync(`gh pr view ${prNumber} -R ${repoFullName} --json state,mergedAt,mergeCommit`, {
stdio: "pipe",
}).toString(),
),
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function pollForMergeable(
ctx: DaemonTestContext,
worktreePath: string,
): Promise<number | undefined> {
const deadline = Date.now() + 30000;
let lastMergeable: PullRequestMergeable = "UNKNOWN";
while (Date.now() < deadline) {
const prStatus = await ctx.client.checkoutPrStatus(worktreePath);
expect(prStatus.error).toBeNull();
expect(prStatus.githubFeaturesEnabled).toBe(true);
expect(prStatus.status).not.toBeNull();
if (prStatus.status?.mergeable === "MERGEABLE") {
return prStatus.status.number;
}
lastMergeable = prStatus.status?.mergeable ?? "UNKNOWN";
await sleep(1500);
}
throw new Error(`Timed out waiting for GitHub PR to become MERGEABLE; last=${lastMergeable}`);
}
function readFetchedFile(repoDir: string, filePath: string): string {
return execSync(`git show FETCH_HEAD:${filePath}`, { cwd: repoDir }).toString();
}
describe("daemon checkout PR merge loop", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
testWithGitHubCliAuth(
"squash-merges a GitHub PR via checkout RPCs",
async () => {
const repoDir = tmpCwd("checkout-pr-merge-");
let repoFullName: string | null = null;
let agentId: string | null = null;
let worktreePath: string | null = null;
let repoCleanupError: unknown;
try {
initGitRepo(repoDir);
const owner = getGhLogin();
const repoName = createTempGithubRepoName("checkout-pr-merge");
repoFullName = `${owner}/${repoName}`;
createPrivateRepo(repoName);
const token = encodeURIComponent(getGhToken());
execSync(
`git remote add origin https://x-access-token:${token}@github.com/${repoFullName}.git`,
{
cwd: repoDir,
stdio: "pipe",
},
);
execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" });
const worktree = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "merge-pr-squash",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "merge-pr-squash",
},
runSetup: true,
paseoHome: ctx.daemon.paseoHome,
});
worktreePath = worktree.worktreePath;
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: worktree.worktreePath,
title: "Checkout PR Merge",
});
agentId = agent.id;
writeFileSync(path.join(worktree.worktreePath, "feature.txt"), "squash merge\n");
const commitResult = await ctx.client.checkoutCommit(worktree.worktreePath, {
message: "Add squash merge fixture",
addAll: true,
});
expect(commitResult.error).toBeNull();
expect(commitResult.success).toBe(true);
const pushResult = await ctx.client.checkoutPush(worktree.worktreePath);
expect(pushResult.error).toBeNull();
expect(pushResult.success).toBe(true);
const prCreate = await ctx.client.checkoutPrCreate(worktree.worktreePath, {
title: "Add squash merge fixture",
body: "Created by the checkout PR merge e2e test.",
baseRef: "main",
});
expect(prCreate.error).toBeNull();
expect(prCreate.url).toContain(repoName);
expect(prCreate.number).not.toBeNull();
if (prCreate.number === null) {
throw new Error("checkoutPrCreate returned success without a PR number");
}
const prNumber = prCreate.number;
const mergeablePrNumber = await pollForMergeable(ctx, worktree.worktreePath);
expect(mergeablePrNumber).toBe(prNumber);
const mergeResult = await ctx.client.checkoutPrMerge(worktree.worktreePath, {
method: "squash",
});
expect(mergeResult.error).toBeNull();
expect(mergeResult.success).toBe(true);
const prView = readGhPrView(prNumber, repoFullName);
expect(prView).toEqual({
state: "MERGED",
mergedAt: expect.any(String),
mergeCommit: {
oid: expect.any(String),
},
});
execSync("git fetch origin main", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git merge-base --is-ancestor main FETCH_HEAD", {
cwd: repoDir,
stdio: "pipe",
});
expect(readFetchedFile(repoDir, "feature.txt")).toBe("squash merge\n");
const archiveResult = await ctx.client.archivePaseoWorktree({
worktreePath: worktree.worktreePath,
});
expect(archiveResult.error).toBeNull();
expect(archiveResult.success).toBe(true);
expect(existsSync(worktree.worktreePath)).toBe(false);
worktreePath = null;
const remainingAgents = await ctx.client.fetchAgents();
expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
agentId = null;
} finally {
if (worktreePath) {
await ctx.client.archivePaseoWorktree({ worktreePath }).catch(() => undefined);
}
if (agentId) {
await ctx.client.deleteAgent(agentId).catch(() => undefined);
}
try {
deleteRepoOrThrow(repoFullName);
} catch (error) {
repoCleanupError = error;
}
rmSync(repoDir, { recursive: true, force: true });
}
if (repoCleanupError) {
throw repoCleanupError;
}
},
180000,
);
});
@@ -4,7 +4,11 @@ import { tmpdir } from "os";
import path from "path";
import { execSync } from "child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import {
createDaemonTestContext,
createTempGithubRepoName,
type DaemonTestContext,
} from "../test-utils/index.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
@@ -76,11 +80,6 @@ function initGitRepo(repoDir: string): void {
});
}
function createTempRepoName(): string {
const rand = Math.random().toString(16).slice(2, 8);
return `paseo-checkout-ship-${Date.now()}-${rand}`;
}
function getGhLogin(): string {
return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim();
}
@@ -128,7 +127,7 @@ describe("daemon checkout ship loop", () => {
initGitRepo(repoDir);
const owner = getGhLogin();
const repoName = createTempRepoName();
const repoName = createTempGithubRepoName("checkout-ship");
repoFullName = `${owner}/${repoName}`;
createPrivateRepo(repoName);
@@ -498,6 +498,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -42,6 +42,7 @@ function createResolverHarness(): ResolverHarness {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -65,6 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutMergeFromBaseRequest(params: unknown): Promise<unknown>;
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
@@ -288,6 +289,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
invalidate: vi.fn(),
searchIssuesAndPrs: vi.fn(),
createPullRequest: vi.fn(),
mergePullRequest: vi.fn(),
};
const checkoutDiffManager = options.checkoutDiffManager ?? {
scheduleRefreshForCwd: vi.fn(),
@@ -1022,6 +1024,7 @@ describe("session PR status payload normalization", () => {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
@@ -1037,6 +1040,7 @@ describe("session PR status payload normalization", () => {
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
});
@@ -1699,6 +1703,91 @@ describe("session checkout pull request creation", () => {
});
});
describe("session checkout pull request merge", () => {
test("merges the current pull request and refreshes GitHub state", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
mergePullRequest: vi.fn().mockResolvedValue({ success: true }),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
pullRequest: {
number: 42,
},
},
}),
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "squash",
requestId: "request-pr-merge",
});
expect(github.mergePullRequest).toHaveBeenCalledWith({
cwd: "/tmp/request-worktree",
prNumber: 42,
mergeMethod: "squash",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
force: true,
reason: "merge-pr",
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
expect(messages).toContainEqual({
type: "checkout_pr_merge_response",
payload: {
cwd: "/tmp/request-worktree",
success: true,
error: null,
requestId: "request-pr-merge",
},
});
});
test("surfaces merge errors verbatim", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
mergePullRequest: vi.fn().mockRejectedValue(new Error("base branch has conflicts")),
};
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue({
github: {
pullRequest: {
number: 42,
},
},
}),
};
const session = createSessionForTest({ github, workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrMergeRequest({
type: "checkout_pr_merge_request",
cwd: "/tmp/request-worktree",
mergeMethod: "merge",
requestId: "request-pr-merge-failure",
});
expect(messages).toContainEqual({
type: "checkout_pr_merge_response",
payload: {
cwd: "/tmp/request-worktree",
success: false,
error: {
code: "UNKNOWN",
message: "base branch has conflicts",
},
requestId: "request-pr-merge-failure",
},
});
});
});
describe("session checkout pull and push handling", () => {
test("forces workspace git and GitHub refresh after pulling", async () => {
const messages: unknown[] = [];
+46
View File
@@ -270,6 +270,7 @@ type GitMutationRefreshReason =
| "push"
| "merge-to-base"
| "merge-from-base"
| "merge-pr"
| "create-pr"
| "switch-branch"
| "create-branch"
@@ -2011,6 +2012,7 @@ export class Session {
});
}
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "checkout_status_request":
@@ -2046,6 +2048,8 @@ export class Session {
return this.handleCheckoutPushRequest(msg);
case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg);
case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request":
@@ -5282,6 +5286,47 @@ export class Session {
}
}
private async handleCheckoutPrMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_merge_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
const prNumber = snapshot.github.pullRequest?.number;
if (typeof prNumber !== "number") {
throw new Error("Unable to determine GitHub pull request number for merge");
}
await this.github.mergePullRequest({
cwd,
prNumber,
mergeMethod: msg.mergeMethod,
});
await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
this.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPrStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> {
@@ -8755,6 +8800,7 @@ export function normalizeCheckoutPrStatusPayload(
headRefName: status.headRefName,
isMerged: status.isMerged,
isDraft: status.isDraft ?? false,
mergeable: status.mergeable ?? "UNKNOWN",
checks: status.checks ?? [],
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
@@ -472,6 +472,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
mergeable: "UNKNOWN",
isDraft: false,
checks: [
{
@@ -531,6 +532,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
mergeable: "UNKNOWN",
isDraft: false,
checks: [],
checksStatus: undefined,
@@ -9,3 +9,4 @@ export {
} from "./daemon-client.js";
export { createDaemonTestContext, type DaemonTestContext } from "./daemon-test-context.js";
export { useTempClaudeConfigDir } from "./claude-config.js";
export { TEMP_GITHUB_REPO_PREFIX, createTempGithubRepoName } from "./temp-github-repo.js";
@@ -0,0 +1,9 @@
// Single namespace for temporary GitHub repos created by Paseo tests.
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
// (collides with real repos like `paseo`, `paseo-website`).
export const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
export function createTempGithubRepoName(category: string): string {
const rand = Math.random().toString(16).slice(2, 8);
return `${TEMP_GITHUB_REPO_PREFIX}${category}-${Date.now()}-${rand}`;
}
@@ -193,6 +193,7 @@ function createGitHubServiceStub(): GitHubService {
url: "https://github.com/acme/repo/pull/1",
number: 1,
})),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(),
};
@@ -176,6 +176,7 @@ function createGitHubServiceStub(): GitHubService {
url: "https://github.com/acme/repo/pull/1",
number: 1,
})),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(),
};
@@ -20,7 +20,11 @@ import {
resolveBranchCheckout,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
createGitHubService,
type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
@@ -78,6 +82,7 @@ export interface WorkspaceGitRuntimeSnapshot {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: Array<{
name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
@@ -39,6 +39,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -149,6 +149,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -24,6 +24,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
interface RunnerCall {
args: string[];
cwd: string;
envOverlay?: Record<string, string>;
}
interface TestRunner {
@@ -46,7 +47,7 @@ function createRunner(stdoutByCall: string[]): TestRunner {
return {
calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
const stdout = stdoutByCall.shift() ?? "[]";
return { stdout, stderr: "" };
},
@@ -60,7 +61,7 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner {
return {
calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
const step = steps.shift() ?? "";
if (typeof step === "string") {
return { stdout: step, stderr: "" };
@@ -81,7 +82,7 @@ function createDeferredRunner(): TestRunner {
return {
calls,
runner: (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
return new Promise((resolve) => {
resolveNext = (stdout: string) => resolve({ stdout, stderr: "" });
});
@@ -125,6 +126,7 @@ function createCurrentPullRequestStatus(
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [],
checksStatus: "none",
reviewDecision: null,
@@ -284,6 +286,33 @@ describe("GitHubService", () => {
vi.useRealTimers();
});
it.each([
["merge", ["pr", "merge", "42", "--merge"]],
["squash", ["pr", "merge", "42", "--squash"]],
["rebase", ["pr", "merge", "42", "--rebase"]],
] as const)("merges pull requests with gh using %s", async (mergeMethod, expectedArgs) => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod,
}),
).resolves.toEqual({ success: true });
expect(runner.calls).toEqual([
{
args: expectedArgs,
cwd: "/tmp/repo",
envOverlay: { GH_PROMPT_DISABLED: "1" },
},
]);
});
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({
@@ -1048,7 +1077,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
]);
expect(status).toEqual({
number: 42,
@@ -1061,6 +1090,7 @@ describe("GitHubService", () => {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "UNKNOWN",
checks: [
{
name: "server-tests",
@@ -1080,6 +1110,25 @@ describe("GitHubService", () => {
});
});
it("defaults unexpected PR mergeability values to unknown", async () => {
const runner = createRunner([
currentPullRequestJson({
mergeable: "",
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
});
expect(status?.mergeable).toBe("UNKNOWN");
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
@@ -1145,7 +1194,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
["repo", "view", "--json", "owner,name,parent"],
[
@@ -1158,7 +1207,7 @@ describe("GitHubService", () => {
"--head",
"forkOwner:feature/fork",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
"--limit",
"10",
],
@@ -1218,7 +1267,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
["repo", "view", "--json", "owner,name,parent"],
]);
@@ -1279,7 +1328,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
[
"pr",
@@ -1289,7 +1338,7 @@ describe("GitHubService", () => {
"--head",
"main",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
"--limit",
"10",
],
@@ -1452,6 +1501,7 @@ describe("GitHubService", () => {
headRefName: "feature",
isMerged: false,
isDraft: true,
mergeable: "UNKNOWN",
checks: [
{
name: "server-tests",
+30 -2
View File
@@ -91,6 +91,8 @@ const HeadRepositoryOwnerSchema = z
.nullable()
.optional();
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(),
url: z.string().catch(""),
@@ -102,6 +104,7 @@ const CurrentPullRequestStatusSchema = z.object({
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
headRepositoryOwner: HeadRepositoryOwnerSchema,
});
@@ -234,7 +237,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner";
const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
@@ -290,6 +293,7 @@ interface GitHubServiceDependencies {
export interface GitHubCommandRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface GitHubCommandResult {
@@ -346,6 +350,7 @@ export interface PullRequestCheck {
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export interface GitHubCurrentPullRequestStatus {
number?: number;
@@ -358,6 +363,7 @@ export interface GitHubCurrentPullRequestStatus {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
@@ -404,6 +410,18 @@ export interface GitHubPullRequestCreateResult {
number: number;
}
export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase";
export interface MergeGitHubPullRequestOptions {
cwd: string;
prNumber: number;
mergeMethod: GitHubPullRequestMergeMethod;
}
export interface GitHubPullRequestMergeResult {
success: true;
}
export type GitHubReadOptions =
| {
force?: false;
@@ -492,6 +510,7 @@ export interface GitHubService {
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
@@ -1030,6 +1049,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return parsed;
},
async mergePullRequest(input) {
await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], {
cwd: input.cwd,
envOverlay: { GH_PROMPT_DISABLED: "1" },
});
return { success: true };
},
isAuthenticated(input) {
return cached({
cwd: input.cwd,
@@ -1167,7 +1194,7 @@ async function runGhCommand(
): Promise<GitHubCommandResult> {
return execCommand("gh", args, {
cwd: options.cwd,
envOverlay: GITHUB_ENV,
envOverlay: { ...GITHUB_ENV, ...options.envOverlay },
maxBuffer: 10 * 1024 * 1024,
});
}
@@ -1690,6 +1717,7 @@ function toCurrentPullRequestStatus(
headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision: mapReviewDecision(item.reviewDecision),
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import { CheckoutPrMergeRequestSchema, CheckoutPrStatusSchema } from "./messages.js";
describe("checkout PR schemas", () => {
test("parses PR status payloads without mergeability", () => {
expect(
CheckoutPrStatusSchema.parse({
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Ship it",
state: "open",
baseRefName: "main",
headRefName: "feature/ship-it",
isMerged: false,
}),
).toMatchObject({
number: 42,
mergeable: "UNKNOWN",
});
});
test.each(["merge", "squash", "rebase"] as const)(
"accepts %s as a PR merge method",
(mergeMethod) => {
expect(
CheckoutPrMergeRequestSchema.parse({
type: "checkout_pr_merge_request",
cwd: "/tmp/repo",
mergeMethod,
requestId: "request-merge-pr",
}),
).toMatchObject({ mergeMethod });
},
);
test("rejects unknown PR merge methods", () => {
expect(() =>
CheckoutPrMergeRequestSchema.parse({
type: "checkout_pr_merge_request",
cwd: "/tmp/repo",
mergeMethod: "auto",
requestId: "request-merge-pr",
}),
).toThrow();
});
});
+28
View File
@@ -1272,6 +1272,13 @@ export const CheckoutPrCreateRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutPrMergeRequestSchema = z.object({
type: z.literal("checkout_pr_merge_request"),
cwd: z.string(),
mergeMethod: z.enum(["merge", "squash", "rebase"]),
requestId: z.string(),
});
export const CheckoutPrStatusRequestSchema = z.object({
type: z.literal("checkout_pr_status_request"),
cwd: z.string(),
@@ -1719,6 +1726,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullRequestSchema,
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
@@ -2661,6 +2669,11 @@ export const CheckoutPrStatusSchema = z.object({
headRefName: z.string(),
isMerged: z.boolean(),
isDraft: z.boolean().optional().default(false),
mergeable: z
.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"])
.catch("UNKNOWN")
.optional()
.default("UNKNOWN"),
checks: z
.array(
z.object({
@@ -2782,6 +2795,16 @@ export const CheckoutPrCreateResponseSchema = z.object({
}),
});
export const CheckoutPrMergeResponseSchema = z.object({
type: z.literal("checkout_pr_merge_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const CheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"),
payload: CheckoutPrStatusPayloadSchema,
@@ -3338,6 +3361,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullResponseSchema,
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
@@ -3585,6 +3609,10 @@ export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type PullRequestTimelineRequest = z.infer<typeof PullRequestTimelineRequestSchema>;
@@ -124,6 +124,7 @@ function createGitHubServiceForStatus(
url: "https://github.com/getpaseo/paseo/pull/1",
number: 1,
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -12,6 +12,7 @@ import {
createGitHubService,
resolveGitHubRepo,
type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
@@ -2309,6 +2310,7 @@ export interface PullRequestStatus {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: PullRequestCheck[];
checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision;