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
@@ -1,300 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import { queryClient as appQueryClient } from "@/query/query-client";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/tmp/repo",
workspaceDirectory: input.workspaceDirectory ?? input.id,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
const deferred = createDeferred<unknown>();
const client = {
checkoutCommit: vi.fn(() => deferred.promise),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, cwd });
const second = store.commit({ serverId, cwd });
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
deferred.resolve({});
await Promise.all([first, second]);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
it("runs pull then push sequentially for pull-and-push", async () => {
const order: string[] = [];
const client = {
checkoutPull: vi.fn(async () => {
order.push("pull");
return {};
}),
checkoutPush: vi.fn(async () => {
order.push("push");
return {};
}),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
expect(order).toEqual(["pull", "push"]);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("success");
});
it("does not push when pull fails for pull-and-push", async () => {
const client = {
checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
checkoutPush: vi.fn(async () => ({})),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("pull conflict");
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("idle");
});
it("surfaces push errors from pull-and-push after a successful pull", async () => {
const client = {
checkoutPull: vi.fn(async () => ({})),
checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("push rejected");
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("idle");
});
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] });
queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] });
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated,
).toBe(false);
queryClient.clear();
});
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
appQueryClient.setQueryData(
["sidebarPaseoWorktreeList", serverId, "/tmp"],
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
);
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
]);
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(true);
});
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: "ws-feature",
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
deferred.resolve({});
await archive;
});
it("restores an optimistically hidden worktree when archive fails", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual(
featureWorkspace,
);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
listSnapshot,
);
});
it("reports local archive pending only while the archive action is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({ id: cwd, name: "feature" });
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
});
});
@@ -1,490 +0,0 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
const SUCCESS_DISPLAY_MS = 1000;
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
export type CheckoutGitAsyncActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
return `${serverId}::${cwd}`;
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client;
}
function setStatus(
key: CheckoutKey,
actionId: CheckoutGitAsyncActionId,
status: CheckoutGitActionStatus,
) {
useCheckoutGitActionsStore.setState((state) => {
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
if (current === status) {
return state;
}
return {
...state,
statusByCheckout: {
...state.statusByCheckout,
[key]: {
...state.statusByCheckout[key],
[actionId]: status,
},
},
};
});
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
{ serverId, cwd }: { serverId: string; cwd: string },
) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
}),
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
);
},
}),
]);
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}
function invalidateWorktreeList() {
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
}
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
const removeFromList = (current: unknown) => {
if (!Array.isArray(current)) {
return current;
}
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
return filtered.length === current.length ? current : filtered;
};
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "paseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
}
interface WorktreeArchiveSnapshot {
workspace: WorkspaceDescriptor | null;
worktreeLists: Array<[QueryKey, unknown]>;
}
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
return (
Array.isArray(input.queryKey) &&
(input.queryKey[0] === "paseoWorktreeList" ||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
input.queryKey[1] === input.serverId
);
}
function snapshotWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
}) ?? input.worktreePath;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
predicate: (query) =>
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
}),
};
}
function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
useSessionStore.getState().removeWorkspace(serverId, worktreePath);
}
function restoreWorktreeArchiveState(input: {
serverId: string;
snapshot: WorktreeArchiveSnapshot;
}): void {
if (input.snapshot.workspace) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
}
for (const [queryKey, data] of input.snapshot.worktreeLists) {
appQueryClient.setQueryData(queryKey, data);
}
}
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.worktreePath.trim();
if (!serverId || !workspaceId) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
return `${key}::${actionId}`;
}
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
return (
useCheckoutGitActionsStore.getState().getStatus({
serverId: input.serverId,
cwd: input.cwd,
actionId: "archive-worktree",
}) === "pending"
);
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
getStatus: (params: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
async function runCheckoutAction({
serverId,
cwd,
actionId,
run,
}: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
const key = checkoutKey(serverId, cwd);
const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId);
if (existing) {
await existing;
return;
}
const prevTimer = successTimers.get(inflightId);
if (prevTimer) {
clearTimeout(prevTimer);
successTimers.delete(inflightId);
}
setStatus(key, actionId, "pending");
const promise = (async () => {
try {
await run();
await invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
successTimers.delete(inflightId);
}, SUCCESS_DISPLAY_MS);
successTimers.set(inflightId, timer);
} catch (error) {
setStatus(key, actionId, "idle");
throw error;
} finally {
inFlight.delete(inflightId);
}
})();
inFlight.set(inflightId, promise);
await promise;
}
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {},
getStatus: ({ serverId, cwd, actionId }) => {
const key = checkoutKey(serverId, cwd);
return get().statusByCheckout[key]?.[actionId] ?? "idle";
},
commit: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "commit",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
pull: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "pull",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPull(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "push",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "pull-and-push",
run: async () => {
const client = resolveClient(serverId);
const pullPayload = await client.checkoutPull(cwd);
if (pullPayload.error) {
throw new Error(pullPayload.error.message);
}
const pushPayload = await client.checkoutPush(cwd);
if (pushPayload.error) {
throw new Error(pushPayload.error.message);
}
},
});
},
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "create-pr",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-branch",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-from-base",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
markWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
});
removeWorktreeFromCachedLists({ serverId, worktreePath });
removeWorktreeFromSessionStore({
serverId,
worktreePath: snapshot.workspace?.id ?? worktreePath,
});
try {
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
clearWorkspaceArchivePending({
serverId,
workspaceId: snapshot.workspace?.id ?? worktreePath,
});
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
purgeArchivedWorkspaceState({ serverId, worktreePath });
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
for (const timer of successTimers.values()) {
clearTimeout(timer);
}
successTimers.clear();
inFlight.clear();
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
}