feat: support creating worktrees from GitHub pull requests

This commit is contained in:
Mohamed Boudra
2026-04-18 12:08:30 +07:00
parent 82f97e942d
commit 14cc1cc83c
48 changed files with 6448 additions and 1515 deletions
+49 -1
View File
@@ -1,6 +1,6 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
type OfferPayload = {
@@ -191,6 +192,47 @@ type OfferPayload = {
relay: { endpoint: string };
};
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
@@ -281,6 +323,7 @@ export default async function globalSetup() {
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -298,6 +341,10 @@ export default async function globalSetup() {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
@@ -482,6 +529,7 @@ export default async function globalSetup() {
cwd: serverDir,
env: {
...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
+45 -1
View File
@@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon(
};
}
export async function clickNewWorkspaceButton(
export async function openNewWorkspaceComposer(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
@@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton(
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton(
await createButton.click();
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
await expect(branchRow).toBeVisible({ timeout: 30_000 });
await branchRow.click();
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
await prRow.click();
}
export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pills = page.getByTestId("composer-github-attachment-pill");
await expect(pills).toHaveCount(1);
await expect(pills.first()).toContainText(`#${input.number}`);
await expect(pills.first()).toContainText(input.title);
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
+56 -1
View File
@@ -5,6 +5,7 @@ import path from "node:path";
type TempRepo = {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
@@ -47,19 +49,72 @@ export const createTempGitRepo = async (
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
}
return {
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
};
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}
+99 -1
View File
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
@@ -8,9 +10,15 @@ import {
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
expectComposerGithubAttachmentPill,
expectStartingRefPickerTriggerPr,
openNewWorkspaceComposer,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
@@ -254,4 +262,94 @@ test.describe("New workspace flow", () => {
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
});
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
});
} finally {
await tempRepo.cleanup();
}
});
});
@@ -59,6 +59,51 @@ describe("submitAgentInput", () => {
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("preserves the composer before an in-flight submit resolves when requested", async () => {
const deferred = createDeferredPromise<void>();
const attachments = [{ id: "img-1" }];
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " keep me ",
attachments,
submitBehavior: "preserve-and-lock",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "keep me",
attachments,
});
expect(setUserInput).not.toHaveBeenCalled();
expect(setAttachments).not.toHaveBeenCalled();
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("queues while the agent is running and clears the composer immediately", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn();
@@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput<TAttachment> {
attachments: TAttachment[];
hasExternalContent?: boolean;
allowEmptySubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
@@ -23,6 +24,7 @@ export async function submitAgentInput<TAttachment>(
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const attachments = input.attachments;
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
if (
!trimmedMessage &&
@@ -39,14 +41,18 @@ export async function submitAgentInput<TAttachment>(
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, attachments });
input.setUserInput("");
input.setAttachments([]);
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setAttachments([]);
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
input.setSendError(null);
input.setIsProcessing(true);
@@ -57,8 +63,10 @@ export async function submitAgentInput<TAttachment>(
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
if (shouldClearOnSubmit) {
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";
@@ -10,6 +10,7 @@ interface AttachmentPillProps {
onRemove: () => void;
openAccessibilityLabel: string;
removeAccessibilityLabel: string;
disabled?: boolean;
testID?: string;
children: ReactNode;
}
@@ -19,6 +20,7 @@ export function AttachmentPill({
onRemove,
openAccessibilityLabel,
removeAccessibilityLabel,
disabled = false,
testID,
children,
}: AttachmentPillProps) {
@@ -33,6 +35,7 @@ export function AttachmentPill({
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button"
@@ -43,6 +46,7 @@ export function AttachmentPill({
</Pressable>
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8}
+39 -2
View File
@@ -176,7 +176,7 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Square: createIcon("Square"),
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
@@ -579,9 +579,13 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
@@ -603,6 +607,8 @@ function ComposerHarness({
return next;
});
}}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
cwd="/repo"
clearDraft={vi.fn()}
/>
@@ -611,7 +617,12 @@ function ComposerHarness({
}
function renderComposer(
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
) {
act(() => {
root?.render(<ComposerHarness {...input} />);
@@ -835,6 +846,32 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("locks the preserved draft while submit loading", () => {
renderComposer({
initialText: "keep this prompt",
initialAttachments: [{ kind: "github_pr", item: prItem }],
isSubmitLoading: true,
submitBehavior: "preserve-and-lock",
});
const textInput = document.querySelector('[aria-label="Message agent..."]');
const attachButton = queryByTestId("message-input-attach-button");
const pill = queryByTestId("composer-github-attachment-pill");
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(textInput).toHaveProperty("readOnly", true);
expect(textInput).toHaveProperty("value", "keep this prompt");
expect(attachButton).toHaveProperty("disabled", true);
expect(pill).not.toBeNull();
expect(removeButton).not.toBeNull();
click(pill!);
click(removeButton!);
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("closes the image lightbox when its close button is pressed", () => {
const image = imageAttachment("img-close");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
+10 -1
View File
@@ -102,6 +102,7 @@ interface ComposerProps {
submitButtonAccessibilityLabel?: string;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value: string;
@@ -142,6 +143,7 @@ export function Composer({
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitLoading = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
onChangeText,
@@ -218,6 +220,7 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
@@ -401,6 +404,7 @@ export function Composer({
hasExternalContent,
allowEmptySubmit,
forceSend,
submitBehavior,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
@@ -815,7 +819,7 @@ export function Composer({
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={styles.inputAreaContainer}>
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
<View style={styles.inputAreaContent}>
{/* Queue list */}
{queuedMessages.length > 0 && (
@@ -874,6 +878,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
disabled={isComposerLocked}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
@@ -890,6 +895,7 @@ export function Composer({
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
disabled={isComposerLocked}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
@@ -1043,6 +1049,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaLocked: {
opacity: 0.6,
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
@@ -51,7 +51,7 @@ vi.mock("lucide-react-native", () => {
return {
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
@@ -20,7 +20,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native";
import { Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
@@ -1181,7 +1181,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={buttonIconSize} color="white" />
<CornerDownLeft size={buttonIconSize} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -0,0 +1,116 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Tooltip, TooltipTrigger } from "./tooltip";
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@gorhom/bottom-sheet", () => ({
useBottomSheetModalInternal: () => null,
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
FadeIn: {},
FadeOut: {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) => styles,
},
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderTrigger({
childDisabled,
onPress,
}: {
childDisabled: boolean;
onPress: () => void;
}): void {
act(() => {
root?.render(
<Tooltip>
<TooltipTrigger asChild>
<Pressable disabled={childDisabled} onPress={onPress} testID="trigger">
<Text>Send</Text>
</Pressable>
</TooltipTrigger>
</Tooltip>,
);
});
}
function pressTrigger(): void {
const trigger = container?.querySelector('[data-testid="trigger"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
describe("TooltipTrigger", () => {
it("keeps an asChild trigger disabled when the child is disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: true, onPress });
pressTrigger();
expect(onPress).not.toHaveBeenCalled();
});
it("keeps an asChild trigger interactive when the child is not disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: false, onPress });
pressTrigger();
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@@ -386,6 +386,7 @@ export function TooltipTrigger({
const mergedProps = {
...childProps,
...triggerProps,
disabled: childProps.disabled || disabled,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { GitHubSearchItem } from "@server/shared/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
const prItem: GitHubSearchItem = {
kind: "pr",
number: 42,
title: "Add picker",
url: "https://example.com/pull/42",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker",
};
describe("pickerItemToCheckoutRequest", () => {
it("returns undefined for no selection (null)", () => {
expect(pickerItemToCheckoutRequest(null)).toBeUndefined();
});
it("maps a branch row to branch-off with the branch name", () => {
const item: PickerItem = { kind: "branch", name: "dev" };
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "branch-off",
refName: "dev",
});
});
it("maps a github-pr row to checkout using the head ref and pr number", () => {
const item: PickerItem = {
kind: "github-pr",
item: prItem,
};
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "feature/picker",
githubPrNumber: 42,
});
});
it("handles a github-pr with a null baseRef", () => {
const item: PickerItem = {
kind: "github-pr",
item: {
...prItem,
number: 7,
title: "Orphan branch",
baseRefName: null,
headRefName: "orphan",
},
};
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "orphan",
githubPrNumber: 7,
});
});
});
@@ -0,0 +1,30 @@
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { GitHubSearchItem } from "@server/shared/messages";
export type PickerItem =
| { kind: "branch"; name: string }
| {
kind: "github-pr";
item: GitHubSearchItem;
};
export type PickerCheckoutRequest = Pick<
CreatePaseoWorktreeInput,
"action" | "refName" | "githubPrNumber"
>;
export function pickerItemToCheckoutRequest(
item: PickerItem | null,
): PickerCheckoutRequest | undefined {
if (!item) return undefined;
switch (item.kind) {
case "branch":
return { action: "branch-off", refName: item.name };
case "github-pr":
return {
action: "checkout",
refName: item.item.headRefName ?? "",
githubPrNumber: item.item.number,
};
}
}
@@ -0,0 +1,781 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { GitHubSearchItem } from "@server/shared/messages";
import { NewWorkspaceScreen } from "./new-workspace-screen";
const {
theme,
mockClient,
mergeWorkspacesMock,
setAgentsMock,
navigateMock,
createdAgent,
createdWorkspace,
prItem,
prItemB,
issueItem,
initialAttachments,
initialDraftState,
} = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { medium: "500" },
shadow: { md: {} },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
border: "#555",
destructive: "#ff453a",
palette: {
zinc: { 600: "#52525b" },
},
},
};
const prItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor picker",
url: "https://example.com/pull/202",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker",
};
const prItemB: GitHubSearchItem = {
kind: "pr",
number: 303,
title: "Polish composer chip",
url: "https://example.com/pull/303",
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/composer-chip",
};
const issueItem: GitHubSearchItem = {
kind: "issue",
number: 44,
title: "Keep manual attachment",
url: "https://example.com/issues/44",
state: "open",
body: null,
labels: [],
};
const initialAttachments: ComposerAttachment[] = [];
const initialDraftState = { text: "" };
const createdWorkspace = {
id: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
};
const createdAgent = {
id: "agent-1",
cwd: createdWorkspace.workspaceDirectory,
};
const mockClient = {
isConnected: true,
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
searchGitHub: vi.fn(async () => ({
items: [prItem, prItemB],
githubFeaturesEnabled: true,
error: null,
})),
createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({
workspace: createdWorkspace,
error: null,
})),
createAgent: vi.fn(async () => createdAgent),
};
return {
theme,
mockClient,
mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(),
createdAgent,
createdWorkspace,
prItem,
prItemB,
issueItem,
initialAttachments,
initialDraftState,
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
HEADER_INNER_HEIGHT: 48,
HEADER_INNER_HEIGHT_MOBILE: 56,
HEADER_TOP_PADDING_MOBILE: 8,
MAX_CONTENT_WIDTH: 900,
useIsCompactFormFactor: () => false,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
GitBranch: createIcon("GitBranch"),
GitPullRequest: createIcon("GitPullRequest"),
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
}));
vi.mock("@/utils/agent-snapshots", () => ({
normalizeAgentSnapshot: (agent: unknown) => agent,
}));
vi.mock("@/utils/encode-images", () => ({
encodeImages: vi.fn(async () => []),
}));
vi.mock("@/utils/error-messages", () => ({
toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}));
vi.mock("@/utils/workspace-execution", () => ({
requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({
workspaceDirectory: input.workspace.workspaceDirectory,
}),
}));
vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateMock,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/hooks/use-agent-input-draft", () => ({
useAgentInputDraft: () => {
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>(initialAttachments);
const [text, setText] = React.useState(initialDraftState.text);
return {
text,
setText,
attachments,
setAttachments,
cwd: "/repo",
composerState: {
selectedProvider: "claude-code",
selectedMode: "",
modeOptions: [],
effectiveModelId: null,
effectiveThinkingOptionId: null,
commandDraftConfig: undefined,
statusControls: {},
},
};
},
}));
vi.mock("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
submitBehavior,
isSubmitLoading,
value,
onChangeText,
attachments,
onChangeAttachments,
}: {
onSubmitMessage: (payload: {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}) => void;
submitBehavior?: "clear" | "preserve-and-lock";
isSubmitLoading?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
}) => (
<div data-testid="test-composer" data-submit-behavior={submitBehavior}>
<textarea
aria-label="Message agent..."
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
value={value}
onChange={(event) => onChangeText(event.currentTarget.value)}
/>
<button
type="button"
data-testid="message-input-attach-button"
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
>
Attach
</button>
{attachments.map((attachment) =>
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
<div
data-testid="composer-github-attachment-pill"
key={`${attachment.kind}-${attachment.item.number}`}
>
#{attachment.item.number} {attachment.item.title}
<button
type="button"
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${
attachment.item.number
}`}
disabled={submitBehavior === "preserve-and-lock" && isSubmitLoading}
onClick={() =>
onChangeAttachments(
attachments.filter(
(candidate) =>
candidate.kind !== attachment.kind ||
candidate.item.number !== attachment.item.number,
),
)
}
>
Remove
</button>
</div>
) : null,
)}
<button
type="button"
data-testid="test-composer-submit"
onClick={() => onSubmitMessage({ text: value, attachments, cwd: "/repo" })}
>
Submit
</button>
</div>
),
}));
vi.mock("@/components/composer-attachments", () => ({
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
images: [],
attachments: attachments.flatMap((attachment) => {
if (attachment.kind === "github_pr") {
return [
{
type: "github_pr",
mimeType: "application/github-pr",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
if (attachment.kind === "github_issue") {
return [
{
type: "github_issue",
mimeType: "application/github-issue",
number: attachment.item.number,
title: attachment.item.title,
url: attachment.item.url,
},
];
}
return [];
}),
}),
}));
vi.mock("@/components/desktop/titlebar-drag-region", () => ({
TitlebarDragRegion: () => null,
}));
vi.mock("@/components/headers/menu-header", () => ({
SidebarMenuToggle: () => null,
}));
vi.mock("@/components/headers/screen-header", () => ({
ScreenHeader: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
options,
renderOption,
onSelect,
}: {
open?: boolean;
options: Array<{ id: string; label: string }>;
renderOption?: (input: {
option: { id: string; label: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
onSelect: (id: string) => void;
}) => {
if (!open) return null;
return (
<div data-testid="ref-picker-combobox">
{options.map((option) =>
renderOption ? (
<React.Fragment key={option.id}>
{renderOption({
option,
selected: false,
active: false,
onPress: () => onSelect(option.id),
})}
</React.Fragment>
) : (
<button type="button" key={option.id} onClick={() => onSelect(option.id)}>
{option.label}
</button>
),
)}
</div>
);
},
ComboboxItem: ({
label,
onPress,
testID,
disabled,
}: {
label: string;
onPress: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onPress}>
{label}
</button>
),
}));
vi.mock("mnemonic-id", () => ({
createNameId: () => "gentle-slug",
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
return actual;
});
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockClient.getBranchSuggestions.mockClear();
mockClient.searchGitHub.mockClear();
mockClient.searchGitHub.mockResolvedValue({
items: [prItem, prItemB],
githubFeaturesEnabled: true,
error: null,
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function renderScreen() {
act(() => {
root?.render(
<QueryClientProvider client={queryClient!}>
<NewWorkspaceScreen serverId="server" sourceDirectory="/repo" />
</QueryClientProvider>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function findByTestId(testID: string): Promise<HTMLElement> {
for (let attempt = 0; attempt < 10; attempt += 1) {
await flush();
const element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
if (element) return element;
}
throw new Error(`Missing element with testID ${testID}`);
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByTestId(testID: string): HTMLElement[] {
return Array.from(document.querySelectorAll(`[data-testid="${testID}"]`)) as HTMLElement[];
}
type CreatePaseoWorktreeArg = Parameters<typeof mockClient.createPaseoWorktree>[0];
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
const calls = mockClient.createPaseoWorktree.mock.calls;
const firstCall = calls[0];
if (!firstCall) throw new Error("createPaseoWorktree not called");
return firstCall[0];
}
describe("NewWorkspaceScreen picker payload", () => {
it("searches only GitHub PRs for the picker", async () => {
renderScreen();
await flush();
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
cwd: "/repo",
query: "",
limit: 20,
kinds: ["github-pr"],
});
});
it("sends no new fields when nothing is selected (default)", async () => {
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
});
expect(call).not.toHaveProperty("refName");
expect(call).not.toHaveProperty("action");
expect(call).not.toHaveProperty("githubPrNumber");
});
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const trigger = await findByTestId("new-workspace-ref-picker-trigger");
expect(trigger.textContent).toContain(`#${prItem.number}`);
expect(trigger.textContent).toContain(prItem.title);
expect(trigger.textContent).not.toContain("feature/picker");
expect(trigger.querySelectorAll('[data-icon="GitPullRequest"]')).toHaveLength(1);
});
it("adds the selected picker PR as a composer attachment pill", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItem.number}`);
expect(pills[0]?.textContent).toContain("Refactor picker");
});
it("replaces the picker-owned composer PR pill when another PR is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItemB.number}`));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${prItemB.number}`);
expect(pills[0]?.textContent).toContain("Polish composer chip");
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("removes the picker-owned PR pill when switching to a branch and preserves user-added chips", async () => {
initialAttachments.push({ kind: "github_issue", item: issueItem });
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
const pills = queryAllByTestId("composer-github-attachment-pill");
expect(pills).toHaveLength(1);
expect(pills[0]?.textContent).toContain(`#${issueItem.number}`);
expect(pills[0]?.textContent).toContain(issueItem.title);
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
});
it("sends action=branch-off with the branch name when a branch row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "branch-off",
refName: "dev",
});
expect(call).not.toHaveProperty("githubPrNumber");
});
it("sends action=checkout with pr number + head ref when a github PR row is selected", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
const call = firstCreateWorktreeCall();
expect(call).toMatchObject({
cwd: "/repo",
worktreeSlug: "gentle-slug",
action: "checkout",
refName: "feature/picker",
githubPrNumber: 202,
});
});
it("sends the selected PR attachment when creating the worktree", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
const attachments = call.attachments ?? [];
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
});
it("keeps the selected PR attachment when the PR query refreshes after selection", async () => {
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
mockClient.searchGitHub.mockResolvedValue({
items: [],
githubFeaturesEnabled: true,
error: null,
});
await act(async () => {
await queryClient?.invalidateQueries({
queryKey: ["new-workspace-github-prs", "server", "/repo"],
});
});
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const call = firstCreateWorktreeCall();
expect(call.attachments).toHaveLength(1);
expect(call.attachments?.[0]).toMatchObject({ type: "github_pr", number: 202 });
});
it("omits PR rows from the picker when GitHub features are disabled", async () => {
mockClient.searchGitHub.mockResolvedValueOnce({
items: [],
githubFeaturesEnabled: false,
error: null,
});
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
expect(queryByTestId(`new-workspace-ref-picker-pr-${prItem.number}`)).toBeNull();
expect((await findByTestId("new-workspace-ref-picker-branch-dev")).textContent).toContain(
"dev",
);
});
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
initialDraftState.text = "please review this change";
const createAgent = createDeferredPromise<typeof createdAgent>();
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
renderScreen();
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
await flush();
click(await findByTestId("new-workspace-ref-picker-trigger"));
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
const textInput = document.querySelector('[aria-label="Message agent..."]');
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", true);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", true);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
createAgent.reject(new Error("Create agent failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");
expect(textInput).toHaveProperty("disabled", false);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(removeButton).toHaveProperty("disabled", false);
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", false);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", false);
});
});
+276 -110
View File
@@ -3,13 +3,11 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SegmentedControlOption } from "@/components/ui/segmented-control";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
@@ -29,8 +27,10 @@ import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { AgentAttachment } from "@server/shared/messages";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
interface NewWorkspaceScreenProps {
serverId: string;
@@ -38,24 +38,69 @@ interface NewWorkspaceScreenProps {
displayName?: string;
}
type BaseRef = { name: string };
interface Checkout {
action: "checkout" | "branch-off";
ref: BaseRef;
interface PickerOptionData {
options: ComboboxOptionType[];
itemById: Map<string, PickerItem>;
firstBranchId: string | null;
firstPrId: string | null;
}
function refId(ref: BaseRef): string {
return `branch:${ref.name}`;
interface PickerSelection {
item: PickerItem;
attachedPrNumber: number | null;
}
function refLabel(ref: BaseRef): string {
return ref.name;
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
function branchOptionId(name: string): string {
return `${BRANCH_OPTION_PREFIX}${name}`;
}
function formatCheckoutBadge(c: Checkout): string {
const label = c.ref.name;
return c.action === "branch-off" ? `new branch off ${label}` : label;
function prOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`;
}
function pickerItemLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function pickerItemTriggerLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
}
function syncPickerPrAttachment(input: {
attachments: ComposerAttachment[];
previousPickerPrNumber: number | null;
item: PickerItem;
}): { attachments: ComposerAttachment[]; attachedPrNumber: number | null } {
let nextAttachments = input.attachments;
let attachedPrNumber: number | null = null;
if (input.previousPickerPrNumber !== null) {
nextAttachments = nextAttachments.filter(
(attachment) =>
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
);
}
if (input.item.kind === "github-pr") {
const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some(
(attachment) =>
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
);
if (!hasExistingPrAttachment) {
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
attachedPrNumber = selectedPr.number;
}
}
return { attachments: nextAttachments, attachedPrNumber };
}
export function NewWorkspaceScreen({
@@ -72,13 +117,14 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const [checkout, setCheckout] = useState<Checkout | null>(null);
const [checkoutPickerOpen, setCheckoutPickerOpen] = useState(false);
const [pickerAction, setPickerAction] = useState<Checkout["action"]>("branch-off");
const checkoutAnchorRef = useRef<View>(null);
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerAnchorRef = useRef<View>(null);
const displayName = displayNameProp?.trim() ?? "";
const workspace = createdWorkspace;
const selectedItem = pickerSelection?.item ?? null;
const isPending = pendingAction !== null;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
@@ -121,41 +167,115 @@ export function NewWorkspaceScreen({
return connectedClient.getBranchSuggestions({ cwd: sourceDirectory, limit: 20 });
},
enabled: isConnected && !!client,
staleTime: 15_000,
});
const checkoutRefs: BaseRef[] = useMemo(
() => (branchSuggestionsQuery.data?.branches ?? []).map((name): BaseRef => ({ name })),
[branchSuggestionsQuery.data?.branches],
const githubPrSearchQuery = useQuery({
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory],
queryFn: async () => {
const connectedClient = withConnectedClient();
return connectedClient.searchGitHub({
cwd: sourceDirectory,
query: "",
limit: 20,
kinds: ["github-pr"],
});
},
enabled: isConnected && !!client,
staleTime: 60_000,
});
const branchNames: string[] = branchSuggestionsQuery.data?.branches ?? [];
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return [];
const items = githubPrSearchQuery.data?.items ?? [];
return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
const { options, itemById, firstBranchId, firstPrId }: PickerOptionData = useMemo(() => {
const collectedOptions: ComboboxOptionType[] = [];
const idMap = new Map<string, PickerItem>();
let firstBranch: string | null = null;
let firstPr: string | null = null;
for (const name of branchNames) {
const id = branchOptionId(name);
collectedOptions.push({ id, label: name });
idMap.set(id, { kind: "branch", name });
if (firstBranch === null) firstBranch = id;
}
for (const pr of prItems) {
if (!pr.headRefName) continue;
const id = prOptionId(pr.number);
collectedOptions.push({ id, label: formatPrLabel(pr) });
idMap.set(id, {
kind: "github-pr",
item: pr,
});
if (firstPr === null) firstPr = id;
}
return {
options: collectedOptions,
itemById: idMap,
firstBranchId: firstBranch,
firstPrId: firstPr,
};
}, [branchNames, prItems]);
const triggerLabel = useMemo(() => {
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
return currentBranch ?? "main";
}, [currentBranch, selectedItem]);
const selectedOptionId = useMemo(() => {
if (!selectedItem) return "";
return selectedItem.kind === "branch"
? branchOptionId(selectedItem.name)
: prOptionId(selectedItem.item.number);
}, [selectedItem]);
const handleSelectOption = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
const next = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
item,
});
setPickerSelection({
item,
attachedPrNumber: next.attachedPrNumber,
});
if (next.attachments !== chatDraft.attachments) {
chatDraft.setAttachments(next.attachments);
}
setPickerOpen(false);
},
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
);
const checkoutOptions: ComboboxOptionType[] = useMemo(
() =>
checkoutRefs.map((ref) => ({
id: refId(ref),
label: refLabel(ref),
})),
[checkoutRefs],
);
const effectiveCheckout: Checkout | null =
checkout ?? (currentBranch ? { action: "branch-off", ref: { name: currentBranch } } : null);
const commitCheckout = useCallback((ref: BaseRef, action: Checkout["action"]) => {
setCheckout({ action, ref });
setCheckoutPickerOpen(false);
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
const openCheckoutPicker = useCallback(() => {
setPickerAction(effectiveCheckout?.action ?? "branch-off");
setCheckoutPickerOpen(true);
}, [effectiveCheckout]);
const buildCreateWorktreeInput = useCallback(
(input: { cwd: string; attachments: AgentAttachment[] }) => {
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
const checkoutModeOptions: SegmentedControlOption<Checkout["action"]>[] = useMemo(
() => [
{ value: "checkout", label: "Check out" },
{ value: "branch-off", label: "Branch off" },
],
[],
return {
cwd: input.cwd,
worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
...(checkoutRequest ?? {}),
};
},
[selectedItem],
);
const ensureWorkspace = useCallback(
@@ -165,11 +285,7 @@ export function NewWorkspaceScreen({
}
const connectedClient = withConnectedClient();
const payload = await connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
const payload = await connectedClient.createPaseoWorktree(buildCreateWorktreeInput(input));
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
@@ -180,7 +296,7 @@ export function NewWorkspaceScreen({
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
},
[createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
);
const handleCreateChatAgent = useCallback(
@@ -251,6 +367,72 @@ export function NewWorkspaceScreen({
addImagesRef.current = addImages;
}, []);
const renderPickerOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOptionType;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const item = itemById.get(option.id);
if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch";
const isFirstInSection =
(isBranch && option.id === firstBranchId) || (!isBranch && option.id === firstPrId);
const header = isFirstInSection ? (
<Text style={styles.sectionHeader}>{isBranch ? "Branches" : "Pull requests"}</Text>
) : null;
const leadingSlot = (
<View style={styles.rowIconBox}>
{isBranch ? (
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
);
const testID = isBranch
? `new-workspace-ref-picker-branch-${item.name}`
: `new-workspace-ref-picker-pr-${item.item.number}`;
const description =
!isBranch && item.item.baseRefName ? `into ${item.item.baseRefName}` : undefined;
return (
<>
{header}
<ComboboxItem
testID={testID}
label={pickerItemLabel(item)}
description={description}
selected={selected}
active={active}
disabled={isPending}
onPress={onPress}
leadingSlot={leadingSlot}
/>
</>
);
},
[
firstBranchId,
firstPrId,
isPending,
itemById,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
);
return (
<View style={styles.container}>
<ScreenHeader
@@ -281,6 +463,7 @@ export function NewWorkspaceScreen({
allowEmptySubmit={true}
submitButtonAccessibilityLabel="Create"
isSubmitLoading={pendingAction === "chat"}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
@@ -296,7 +479,7 @@ export function NewWorkspaceScreen({
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
disabled: isPending,
}
: undefined
}
@@ -307,21 +490,31 @@ export function NewWorkspaceScreen({
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={checkoutAnchorRef}
onPress={openCheckoutPicker}
ref={pickerAnchorRef}
testID="new-workspace-ref-picker-trigger"
onPress={openPicker}
disabled={isPending}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
hovered && !isPending && styles.badgeHovered,
pressed && !isPending && styles.badgePressed,
isPending && styles.badgeDisabled,
]}
accessibilityRole="button"
accessibilityLabel="Checkout ref"
accessibilityLabel="Starting ref"
>
<View style={styles.badgeIconBox}>
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{selectedItem?.kind === "github-pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{effectiveCheckout ? formatCheckoutBadge(effectiveCheckout) : "main"}
{triggerLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -331,51 +524,18 @@ export function NewWorkspaceScreen({
</TooltipContent>
</Tooltip>
<Combobox
options={checkoutOptions}
value={effectiveCheckout ? refId(effectiveCheckout.ref) : ""}
onSelect={(id) => {
const ref = checkoutRefs.find((r) => refId(r) === id);
if (ref) commitCheckout(ref, pickerAction);
}}
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches"
searchPlaceholder="Search branches and PRs"
title="Start from"
open={checkoutPickerOpen}
onOpenChange={setCheckoutPickerOpen}
open={pickerOpen}
onOpenChange={setPickerOpen}
desktopPlacement="bottom-start"
anchorRef={checkoutAnchorRef}
emptyText="No branches."
stickyHeader={
<View style={styles.checkoutPickerHeader}>
<SegmentedControl
size="sm"
options={checkoutModeOptions}
value={pickerAction}
onValueChange={setPickerAction}
/>
</View>
}
renderOption={({ option, selected, active, onPress }) => {
const ref = checkoutRefs.find((r) => refId(r) === option.id);
if (!ref) return <View key={option.id} />;
return (
<ComboboxItem
key={option.id}
label={refLabel(ref)}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<View style={styles.rowIconBox}>
<GitBranch
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</View>
}
/>
);
}}
anchorRef={pickerAnchorRef}
emptyText="No matching refs."
renderOption={renderPickerOption}
/>
</View>
</View>
@@ -458,6 +618,9 @@ const styles = StyleSheet.create((theme) => ({
badgePressed: {
backgroundColor: theme.colors.surface0,
},
badgeDisabled: {
opacity: 0.6,
},
badgeText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
@@ -467,12 +630,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
checkoutPickerHeader: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
alignItems: "flex-start",
},
badgeIconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
@@ -486,4 +643,13 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0,
},
}));
@@ -413,6 +413,133 @@ describe("DaemonClient", () => {
await expect(createPromise).rejects.toThrow("attachment test sentinel");
});
test("sends worktree base-ref fields in create_agent_request git options", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-ref",
git: {
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
},
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_agent_request";
requestId: string;
git: {
createWorktree: boolean;
worktreeSlug: string;
refName: string;
action: string;
githubPrNumber: number;
};
};
};
expect(request.message.git).toEqual({
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
});
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.message.requestId,
error: "git ref fields sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
});
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-legacy",
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
});
expect(String(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/tmp/project",
},
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
labels: {},
requestId: "req-agent-legacy",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: "req-agent-legacy",
error: "legacy git shape sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
});
test("sends structured attachments with create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -483,6 +610,134 @@ describe("DaemonClient", () => {
});
});
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createPaseoWorktree(
{
cwd: "/tmp/project",
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
},
"req-worktree-ref",
);
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_paseo_worktree_request";
requestId: string;
cwd: string;
worktreeSlug: string;
refName: string;
action: string;
githubPrNumber: number;
};
};
expect(request.message).toEqual({
type: "create_paseo_worktree_request",
cwd: "/tmp/project",
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
requestId: "req-worktree-ref",
});
mock.triggerMessage(
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: request.message.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: request.message.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
});
});
test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createPaseoWorktree(
{
cwd: "/tmp/project",
worktreeSlug: "feature-a",
},
"req-worktree-legacy",
);
expect(String(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
type: "create_paseo_worktree_request",
cwd: "/tmp/project",
worktreeSlug: "feature-a",
requestId: "req-worktree-legacy",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: "req-worktree-legacy",
workspace: null,
error: "legacy worktree shape sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: "req-worktree-legacy",
workspace: null,
error: "legacy worktree shape sentinel",
setupTerminalId: null,
});
});
test("sends explicit shutdown_server_request via shutdownServer", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+16 -9
View File
@@ -16,8 +16,8 @@ import type {
AgentSnapshotPayload,
ProjectPlacementPayload,
AgentPermissionResolvedMessage,
AgentAttachment,
CreateAgentRequestMessage,
CreatePaseoWorktreeRequest,
FileDownloadTokenResponse,
FileExplorerResponse,
FetchAgentTimelineResponseMessage,
@@ -37,6 +37,7 @@ import type {
ValidateBranchResponse,
BranchSuggestionsResponse,
GitHubSearchResponse,
GitHubSearchRequest,
DirectorySuggestionsResponse,
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
@@ -206,7 +207,7 @@ export type SendMessageOptions = {
type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>;
export type CreateAgentRequestOptions = {
export interface CreateAgentRequestOptions extends AgentConfigOverrides {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
@@ -220,7 +221,13 @@ export type CreateAgentRequestOptions = {
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
} & AgentConfigOverrides;
}
export interface CreatePaseoWorktreeInput
extends Pick<
CreatePaseoWorktreeRequest,
"cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber"
> {}
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type SubscribeCheckoutDiffPayload = Extract<
@@ -2576,11 +2583,7 @@ export class DaemonClient {
}
async createPaseoWorktree(
input: {
cwd: string;
worktreeSlug?: string;
attachments?: AgentAttachment[];
},
input: CreatePaseoWorktreeInput,
requestId?: string,
): Promise<CreatePaseoWorktreePayload> {
return this.sendCorrelatedSessionRequest({
@@ -2592,6 +2595,9 @@ export class DaemonClient {
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
...(input.refName !== undefined ? { refName: input.refName } : {}),
...(input.action !== undefined ? { action: input.action } : {}),
...(input.githubPrNumber !== undefined ? { githubPrNumber: input.githubPrNumber } : {}),
},
responseType: "create_paseo_worktree_response",
timeout: 60000,
@@ -2632,7 +2638,7 @@ export class DaemonClient {
}
async searchGitHub(
options: { cwd: string; query: string; limit?: number },
options: { cwd: string; query: string; limit?: number; kinds?: GitHubSearchRequest["kinds"] },
requestId?: string,
): Promise<GitHubSearchPayload> {
return this.sendCorrelatedSessionRequest({
@@ -2642,6 +2648,7 @@ export class DaemonClient {
cwd: options.cwd,
query: options.query,
limit: options.limit,
kinds: options.kinds,
},
responseType: "github_search_response",
timeout: 15000,
@@ -9,7 +9,41 @@ import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { createAllClients, shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
import {
createWorktree as createWorktreePrimitive,
validateBranchSlug,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "../../utils/worktree.js";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -104,7 +138,7 @@ function initGitRepo(repoDir: string): void {
test("renames the worktree branch using a real Codex agent", async () => {
const worktreeSlug = "metadata-worktree";
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: worktreeSlug,
cwd: repoDir,
baseBranch: "main",
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -10,6 +11,14 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { AgentSnapshotPayloadSchema } from "../../shared/messages.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
} from "../paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "../worktree-core.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js";
type TestDeps = {
agentManager: AgentManager;
@@ -35,6 +44,8 @@ function createTestDeps(): TestDeps {
getTimeline: vi.fn().mockReturnValue([]),
resumeAgentFromPersistence: vi.fn(),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
@@ -114,6 +125,76 @@ function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): StoredA
};
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
createdWorkspaceIds?: string[];
}): CreatePaseoWorktreeFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: options.paseoHome,
deps: { github },
});
return async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktreeService(input, {
...coreDeps,
...(serviceOptions?.resolveRepositoryDefaultBranch
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
},
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async () => {},
broadcastWorkspaceUpdate: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
});
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
return result;
};
}
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -165,6 +246,49 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("accepts optional worktree intent fields in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"];
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
title: "Short title",
initialPrompt: "test",
worktreeName: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
expect(parsed.success).toBe(true);
});
it("accepts optional worktree intent fields in create_worktree input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_worktree"];
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
expect(parsed.success).toBe(true);
});
it("rejects create_worktree without a branch name or checkout intent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_worktree"];
await expect(tool.callback({})).rejects.toThrow(
"create_worktree requires branchName, refName, or githubPrNumber",
);
});
it("surfaces createAgent validation failures", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockRejectedValue(
@@ -274,6 +398,107 @@ describe("create_agent MCP tool", () => {
);
});
it("registers and broadcasts a workspace when create_agent creates a worktree", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-with-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
}),
logger,
});
const tool = (server as any)._registeredTools["create_agent"];
await tool.callback({
cwd: repoDir,
title: "Worktree agent",
initialPrompt: "Do work",
worktreeName: "agent-worktree",
baseBranch: "main",
background: true,
});
expect(broadcasts).toHaveLength(1);
expect(createdWorkspaceIds).toHaveLength(1);
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: expect.stringContaining("agent-worktree"),
}),
undefined,
undefined,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = (server as any)._registeredTools["create_worktree"];
const response = await tool.callback({
cwd: repoDir,
branchName: "tool-worktree",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree");
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("accepts custom provider IDs in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
+90 -25
View File
@@ -20,18 +20,21 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import {
deletePaseoWorktree,
listPaseoWorktrees,
type WorktreeConfig,
} from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js";
import {
AgentModelSchema,
AgentProviderEnum,
@@ -47,6 +50,13 @@ import {
toScheduleSummary,
waitForAgentWithTimeout,
} from "./mcp-shared.js";
import type { GitHubService } from "../../services/github-service.js";
import type {
CreatePaseoWorktreeFn,
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -55,6 +65,8 @@ export interface AgentMcpServerOptions {
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
github?: GitHubService;
createPaseoWorktree?: CreatePaseoWorktreeFn;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -382,6 +394,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.string()
.optional()
.describe("Required when worktreeName is set: the base branch to diff/merge against."),
refName: z.string().min(1).optional().describe("Optional source ref for worktree creation."),
action: z
.enum(["branch-off", "checkout"])
.optional()
.describe("Optional worktree creation action."),
githubPrNumber: z
.number()
.int()
.positive()
.optional()
.describe("Optional GitHub pull request number to checkout."),
background: z
.boolean()
.optional()
@@ -519,24 +542,31 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
thinking = topLevelArgs.thinking;
labels = topLevelArgs.labels;
notifyOnFinish = topLevelArgs.notifyOnFinish ?? false;
const { cwd, mode, worktreeName, baseBranch } = topLevelArgs;
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } =
topLevelArgs;
resolvedCwd = expandUserPath(cwd);
if (worktreeName) {
if (!baseBranch) {
if (!baseBranch && !refName && !action && githubPrNumber === undefined) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktreeBootstrap = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
worktreeSlug: worktreeName,
paseoHome: options.paseoHome,
const createdWorktree = await createMcpWorktree({
input: {
cwd: resolvedCwd,
worktreeSlug: worktreeName,
refName,
action,
githubPrNumber,
runSetup: false,
paseoHome: options.paseoHome,
},
createPaseoWorktree: options.createPaseoWorktree,
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
});
resolvedCwd = worktreeBootstrap.worktree.worktreePath;
worktreeConfig = worktreeBootstrap.worktree;
shouldBootstrapWorktree = worktreeBootstrap.shouldBootstrap;
resolvedCwd = createdWorktree.worktree.worktreePath;
worktreeConfig = createdWorktree.worktree;
shouldBootstrapWorktree = createdWorktree.created;
}
resolvedMode = mode;
@@ -1513,28 +1543,42 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.string()
.optional()
.describe("Optional repository cwd. Defaults to the caller agent cwd."),
branchName: z.string(),
baseBranch: z.string(),
branchName: z.string().optional(),
baseBranch: z.string().optional(),
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
githubPrNumber: z.number().int().positive().optional(),
},
outputSchema: {
branchName: z.string(),
worktreePath: z.string(),
},
},
async ({ cwd, branchName, baseBranch }) => {
const worktree = await createAgentWorktree({
branchName,
cwd: resolveScopedCwd(cwd, { required: true }),
baseBranch,
worktreeSlug: branchName,
paseoHome: options.paseoHome,
async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => {
if (!branchName && !refName && githubPrNumber === undefined) {
throw new Error("create_worktree requires branchName, refName, or githubPrNumber");
}
const repoRoot = resolveScopedCwd(cwd, { required: true });
const createdWorktree = await createMcpWorktree({
input: {
cwd: repoRoot,
worktreeSlug: branchName,
refName,
action,
githubPrNumber,
runSetup: false,
paseoHome: options.paseoHome,
},
createPaseoWorktree: options.createPaseoWorktree,
resolveRepositoryDefaultBranch: baseBranch ? async () => baseBranch : undefined,
});
const { worktree } = createdWorktree;
return {
content: [],
structuredContent: ensureValidJson({
branchName,
worktreePath: worktree.worktree.worktreePath,
branchName: worktree.branchName,
worktreePath: worktree.worktreePath,
}),
};
},
@@ -1711,3 +1755,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return server;
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeFn | undefined;
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(options.input, {
resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch,
});
} catch (error) {
throw toWorktreeRequestError(error);
}
}
+39
View File
@@ -87,6 +87,9 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
}
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js";
import { createPaseoWorktree } from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
@@ -424,9 +427,13 @@ export async function createPaseoDaemon(
});
const terminalManager = createTerminalManager();
const github = createGitHubService();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
paseoHome: config.paseoHome,
deps: {
github,
},
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
@@ -492,6 +499,37 @@ export async function createPaseoDaemon(
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
scheduleService,
providerRegistry,
github,
createPaseoWorktree: (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
return createPaseoWorktree(input, {
...coreDeps,
...(serviceOptions?.resolveRepositoryDefaultBranch
? {
resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) =>
session.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
) ?? [],
);
},
broadcastWorkspaceUpdate: async (workspaceId) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdateForWorkspaceId(workspaceId)) ?? [],
);
},
});
},
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -688,6 +726,7 @@ export async function createPaseoDaemon(
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
workspaceGitService,
github,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
@@ -5,7 +5,40 @@ import path from "path";
import { execSync } from "child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createWorktree } from "../../utils/worktree.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "../../utils/worktree.js";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -109,7 +142,7 @@ describe("daemon checkout ship loop", () => {
);
execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" });
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "ship-loop",
cwd: repoDir,
baseBranch: "main",
@@ -256,7 +289,7 @@ describe("daemon checkout ship loop", () => {
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir, stdio: "pipe" });
execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" });
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "merge-from-base",
cwd: repoDir,
baseBranch: "main",
@@ -0,0 +1,274 @@
import { execSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
describe("createPaseoWorktree", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("creates a worktree, registers the workspace, primes fingerprints, and broadcasts in order", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const deps = createDeps({ events });
const result = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "feature-one",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(events).toEqual([
`project:${result.workspace.projectId}`,
`workspace:${result.workspace.workspaceId}`,
`prime:${result.workspace.workspaceId}`,
`broadcast:${result.workspace.workspaceId}`,
]);
});
test("reuses an existing worktree and still upserts and broadcasts", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const first = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
firstDeps,
);
const events: string[] = [];
const deps = createDeps({
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
});
const second = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "reuse-me",
runSetup: false,
paseoHome,
},
deps,
);
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
expect(events).toContain(`broadcast:${second.workspace.workspaceId}`);
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
cleanupPaths.push(tempDir);
const deps = createDeps();
await expect(
createPaseoWorktree(
{
cwd: tempDir,
worktreeSlug: "not-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
),
).rejects.toThrow("Create worktree requires a git repository");
expect(deps.projects.size).toBe(0);
expect(deps.workspaces.size).toBe(0);
expect(deps.broadcastWorkspaceUpdate).not.toHaveBeenCalled();
expect(deps.primeWorkspaceGitWatchFingerprints).not.toHaveBeenCalled();
});
test("keeps direct core worktree creation calls behind the service boundary", () => {
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
if (path.basename(filePath) === "worktree-core.ts") {
return [];
}
const contents = readFileSync(filePath, "utf8");
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
});
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
});
});
interface TestDeps extends CreatePaseoWorktreeDeps {
projects: Map<string, PersistedProjectRecord>;
workspaces: Map<string, PersistedWorkspaceRecord>;
}
function createDeps(options?: {
events?: string[];
projects?: Map<string, PersistedProjectRecord>;
workspaces?: Map<string, PersistedWorkspaceRecord>;
}): TestDeps {
const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
return {
...createWorktreeCoreDeps(createGitHubServiceStub()),
projects,
workspaces,
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
events.push(`project:${record.projectId}`);
projects.set(record.projectId, record);
},
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
events.push(`workspace:${record.workspaceId}`);
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService: createWorkspaceGitServiceStub(),
primeWorkspaceGitWatchFingerprints: vi.fn(async (workspace: PersistedWorkspaceRecord) => {
events.push(`prime:${workspace.workspaceId}`);
}),
broadcastWorkspaceUpdate: vi.fn(async (workspaceId: string) => {
events.push(`broadcast:${workspaceId}`);
}),
};
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createWorkspaceGitServiceStub(): WorkspaceGitService {
return {
subscribe: async (params) => ({
initial: createWorkspaceGitSnapshot(params.cwd),
unsubscribe: () => {},
}),
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
refresh: async () => {},
requestWorkingTreeWatch: async (cwd) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
function createWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
const repoRoot = execSync("git rev-parse --show-toplevel", { cwd, stdio: "pipe" })
.toString()
.trim();
const mainRepoRoot = execSync("git rev-parse --path-format=absolute --git-common-dir", {
cwd,
stdio: "pipe",
})
.toString()
.trim()
.replace(/\/\.git$/, "");
const currentBranch = execSync("git branch --show-current", { cwd, stdio: "pipe" })
.toString()
.trim();
return {
cwd,
git: {
isGit: true,
repoRoot,
mainRepoRoot,
currentBranch,
remoteUrl: null,
isPaseoOwnedWorktree: repoRoot !== mainRepoRoot,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
refreshedAt: null,
},
};
}
function createGitRepo(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
const repoDir = path.join(tempDir, "repo");
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}
function listTypeScriptFiles(directory: string): string[] {
const entries = readdirSync(directory);
return entries.flatMap((entry) => {
const fullPath = path.join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
return listTypeScriptFiles(fullPath);
}
return fullPath.endsWith(".ts") ? [fullPath] : [];
});
}
@@ -0,0 +1,132 @@
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import {
buildProjectPlacementForCwd,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceId,
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import {
createWorktreeCore,
type CreateWorktreeCoreDeps,
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import type { WorktreeConfig } from "../utils/worktree.js";
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
export interface CreatePaseoWorktreeResult {
worktree: WorktreeConfig;
intent: WorktreeCreationIntent;
workspace: PersistedWorkspaceRecord;
repoRoot: string;
created: boolean;
}
export type CreatePaseoWorktreeFn = (
input: CreatePaseoWorktreeInput,
options?: {
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
},
) => Promise<CreatePaseoWorktreeResult>;
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
workspaceGitService: WorkspaceGitService;
broadcastWorkspaceUpdate: (workspaceId: string) => Promise<void> | void;
primeWorkspaceGitWatchFingerprints: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}
export async function createPaseoWorktree(
input: CreatePaseoWorktreeInput,
deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps);
const workspace = await upsertWorkspaceForWorktree({
worktree: createdWorktree.worktree,
deps,
});
await deps.primeWorkspaceGitWatchFingerprints(workspace);
await deps.broadcastWorkspaceUpdate(workspace.workspaceId);
return {
worktree: createdWorktree.worktree,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
created: createdWorktree.created,
};
}
async function upsertWorkspaceForWorktree(options: {
worktree: WorktreeConfig;
deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
}): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
workspaceGitService: options.deps.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const now = new Date().toISOString();
const existingProject = await options.deps.projectRegistry.get(placement.projectKey);
const existingWorkspace = await findWorkspaceByDirectory(
normalizedCwd,
options.deps.workspaceRegistry,
);
await options.deps.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
archivedAt: null,
}),
);
const workspace = createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: normalizedCwd,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
archivedAt: null,
});
await options.deps.workspaceRegistry.upsert(workspace);
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
}
async function findWorkspaceByDirectory(
cwd: string,
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
): Promise<PersistedWorkspaceRecord | null> {
const workspaces = await workspaceRegistry.list();
return workspaces.find((workspace) => workspace.cwd === cwd) ?? null;
}
@@ -0,0 +1,233 @@
import { describe, expect, test } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import type { AgentAttachment } from "./messages.js";
import {
ConflictingGitHubPullRequestIntentError,
MissingCheckoutTargetError,
resolveWorktreeCreationIntent,
} from "./resolve-worktree-creation-intent.js";
interface GitHubHeadRefLookup {
cwd: string;
number: number;
}
interface ResolverHarness {
github: GitHubService;
headRefLookups: GitHubHeadRefLookup[];
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
function createResolverHarness(): ResolverHarness {
const headRefLookups: GitHubHeadRefLookup[] = [];
const github: GitHubService = {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ cwd, number }) => {
headRefLookups.push({ cwd, number });
return `pr-${number}`;
},
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
return {
github,
headRefLookups,
resolveRepositoryDefaultBranch: async () => "main",
generateBranchName: (seed) => seed ?? "generated-worktree",
};
}
function createGitHubPrAttachment(params: {
number: number;
headRefName?: string;
baseRefName?: string;
}): AgentAttachment {
return {
type: "github_pr",
mimeType: "application/github-pr",
number: params.number,
title: `PR ${params.number}`,
url: `https://github.com/acme/repo/pull/${params.number}`,
...(params.headRefName ? { headRefName: params.headRefName } : {}),
...(params.baseRefName ? { baseRefName: params.baseRefName } : {}),
};
}
describe("resolveWorktreeCreationIntent", () => {
const repoRoot = "/tmp/repo";
test("branches off the repo default branch when no explicit fields are set", async () => {
const deps = createResolverHarness();
await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "generated-worktree",
});
expect(deps.headRefLookups).toEqual([]);
});
test("branches off the explicit refName when action is branch-off", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{ action: "branch-off", refName: "dev", worktreeSlug: "feature" },
repoRoot,
deps,
),
).resolves.toEqual({
kind: "branch-off",
baseBranch: "dev",
newBranchName: "feature",
});
expect(deps.headRefLookups).toEqual([]);
});
test("checks out an explicit branch target", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent({ action: "checkout", refName: "dev" }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-branch",
branchName: "dev",
});
expect(deps.headRefLookups).toEqual([]);
});
test("checks out a GitHub PR target and resolves its head ref", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent({ action: "checkout", githubPrNumber: 42 }, repoRoot, deps),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 42,
headRef: "pr-42",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([{ cwd: repoRoot, number: 42 }]);
});
test("uses an explicit PR head ref without calling GitHub", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42, refName: "head-ref" },
repoRoot,
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 42,
headRef: "head-ref",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([]);
});
test("rejects checkout without a target", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent({ action: "checkout" }, repoRoot, deps),
).rejects.toBeInstanceOf(MissingCheckoutTargetError);
});
test("preserves the legacy GitHub PR attachment checkout path", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{ attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })] },
repoRoot,
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 5,
headRef: "pr-five",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([]);
});
test("accepts matching explicit and attachment GitHub PR numbers", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "checkout",
githubPrNumber: 5,
attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })],
},
repoRoot,
deps,
),
).resolves.toEqual({
kind: "checkout-github-pr",
githubPrNumber: 5,
headRef: "pr-five",
baseRefName: "main",
});
expect(deps.headRefLookups).toEqual([]);
});
test("rejects conflicting explicit and attachment GitHub PR numbers", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "checkout",
githubPrNumber: 5,
attachments: [createGitHubPrAttachment({ number: 7, headRefName: "pr-seven" })],
},
repoRoot,
deps,
),
).rejects.toBeInstanceOf(ConflictingGitHubPullRequestIntentError);
});
test("ignores a GitHub PR attachment for explicit branch-off", async () => {
const deps = createResolverHarness();
await expect(
resolveWorktreeCreationIntent(
{
action: "branch-off",
attachments: [createGitHubPrAttachment({ number: 5, headRefName: "pr-five" })],
},
repoRoot,
deps,
),
).resolves.toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "generated-worktree",
});
expect(deps.headRefLookups).toEqual([]);
});
});
@@ -0,0 +1,198 @@
import type { GitHubService } from "../services/github-service.js";
import type { AgentAttachment } from "./messages.js";
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = WorktreeSource;
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
attachments?: AgentAttachment[];
}
export interface ResolveWorktreeCreationIntentDeps {
github: GitHubService;
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
export class MissingCheckoutTargetError extends Error {
readonly action = "checkout";
constructor() {
super('action "checkout" requires refName or githubPrNumber');
this.name = "MissingCheckoutTargetError";
}
}
export class ConflictingGitHubPullRequestIntentError extends Error {
readonly explicitGitHubPrNumber: number;
readonly attachmentGitHubPrNumber: number;
constructor(params: { explicitGitHubPrNumber: number; attachmentGitHubPrNumber: number }) {
super(
`Conflicting GitHub PR intent: explicit PR #${params.explicitGitHubPrNumber} does not match attachment PR #${params.attachmentGitHubPrNumber}`,
);
this.name = "ConflictingGitHubPullRequestIntentError";
this.explicitGitHubPrNumber = params.explicitGitHubPrNumber;
this.attachmentGitHubPrNumber = params.attachmentGitHubPrNumber;
}
}
export async function resolveWorktreeCreationIntent(
input: ResolveWorktreeCreationIntentInput,
repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps,
): Promise<WorktreeCreationIntent> {
const githubPrAttachment = findGitHubPrAttachment(input.attachments);
assertGitHubPrIntentAgreesWithAttachment({
githubPrNumber: input.githubPrNumber,
githubPrAttachment,
});
if (input.action === "branch-off") {
return {
kind: "branch-off",
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
newBranchName: deps.generateBranchName(input.worktreeSlug),
};
}
if (input.action === "checkout") {
if (input.githubPrNumber !== undefined) {
return {
kind: "checkout-github-pr",
githubPrNumber: input.githubPrNumber,
headRef: await resolveGitHubPrHeadRef({
refName: input.refName,
githubPrNumber: input.githubPrNumber,
githubPrAttachment,
repoRoot,
deps,
}),
baseRefName: await resolveGitHubPrBaseRefName(githubPrAttachment, repoRoot, deps),
};
}
const branchName = input.refName?.trim();
if (branchName) {
return {
kind: "checkout-branch",
branchName,
};
}
throw new MissingCheckoutTargetError();
}
if (input.githubPrNumber !== undefined) {
return {
kind: "checkout-github-pr",
githubPrNumber: input.githubPrNumber,
headRef: await resolveGitHubPrHeadRef({
refName: input.refName,
githubPrNumber: input.githubPrNumber,
githubPrAttachment,
repoRoot,
deps,
}),
baseRefName: await resolveGitHubPrBaseRefName(githubPrAttachment, repoRoot, deps),
};
}
if (input.refName?.trim()) {
return {
kind: "branch-off",
baseBranch: input.refName.trim(),
newBranchName: deps.generateBranchName(input.worktreeSlug),
};
}
if (githubPrAttachment) {
return {
kind: "checkout-github-pr",
githubPrNumber: githubPrAttachment.number,
headRef: await resolveGitHubPrAttachmentHeadRef(githubPrAttachment, repoRoot, deps),
baseRefName: await resolveGitHubPrBaseRefName(githubPrAttachment, repoRoot, deps),
};
}
return {
kind: "branch-off",
baseBranch: await resolveDefaultBranch(repoRoot, deps),
newBranchName: deps.generateBranchName(input.worktreeSlug),
};
}
async function resolveDefaultBranch(
repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps,
): Promise<string> {
const baseBranch = await deps.resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
return baseBranch;
}
function assertGitHubPrIntentAgreesWithAttachment(params: {
githubPrNumber?: number;
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | null;
}): void {
if (
params.githubPrNumber !== undefined &&
params.githubPrAttachment &&
params.githubPrNumber !== params.githubPrAttachment.number
) {
throw new ConflictingGitHubPullRequestIntentError({
explicitGitHubPrNumber: params.githubPrNumber,
attachmentGitHubPrNumber: params.githubPrAttachment.number,
});
}
}
async function resolveGitHubPrHeadRef(params: {
refName?: string;
githubPrNumber: number;
githubPrAttachment: Extract<AgentAttachment, { type: "github_pr" }> | null;
repoRoot: string;
deps: ResolveWorktreeCreationIntentDeps;
}): Promise<string> {
const trimmedRefName = params.refName?.trim();
if (trimmedRefName) {
return trimmedRefName;
}
if (params.githubPrAttachment) {
const attachmentHeadRef = params.githubPrAttachment.headRefName?.trim();
if (attachmentHeadRef) {
return attachmentHeadRef;
}
}
return params.deps.github.getPullRequestHeadRef({
cwd: params.repoRoot,
number: params.githubPrNumber,
});
}
async function resolveGitHubPrAttachmentHeadRef(
attachment: Extract<AgentAttachment, { type: "github_pr" }>,
repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps,
): Promise<string> {
const trimmed = attachment.headRefName?.trim();
if (trimmed && trimmed.length > 0) {
return trimmed;
}
return deps.github.getPullRequestHeadRef({ cwd: repoRoot, number: attachment.number });
}
async function resolveGitHubPrBaseRefName(
attachment: Extract<AgentAttachment, { type: "github_pr" }> | null,
repoRoot: string,
deps: ResolveWorktreeCreationIntentDeps,
): Promise<string> {
return attachment?.baseRefName?.trim() || (await resolveDefaultBranch(repoRoot, deps));
}
+88 -96
View File
@@ -191,6 +191,13 @@ import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
createPaseoWorktree,
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
@@ -200,8 +207,8 @@ import {
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
@@ -473,6 +480,7 @@ export type SessionOptions = {
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
github?: GitHubService;
createAgentMcpTransport?: AgentMcpTransportFactory;
workspaceGitService: WorkspaceGitService;
daemonConfigStore: DaemonConfigStore;
@@ -633,6 +641,7 @@ export class Session {
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null;
@@ -711,6 +720,7 @@ export class Session {
scheduleService,
loopService,
checkoutDiffManager,
github,
workspaceGitService,
daemonConfigStore,
mcpBaseUrl,
@@ -752,6 +762,7 @@ export class Session {
this.scheduleService = scheduleService;
this.loopService = loopService;
this.checkoutDiffManager = checkoutDiffManager;
this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService;
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl ?? null;
@@ -826,6 +837,17 @@ export class Session {
}
}
async primeWorkspaceGitWatchFingerprintForWorkspace(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
await this.primeWorkspaceGitWatchFingerprints([descriptor]);
}
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
/**
* Get the client's current activity state
*/
@@ -1285,65 +1307,6 @@ export class Session {
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
return buildProjectPlacementForCwdStandalone({
cwd,
workspaceGitService: this.workspaceGitService,
});
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.cwd,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.cwd,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.cwd,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
@@ -2880,6 +2843,7 @@ export class Session {
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
} catch (error: any) {
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
this.emit({
@@ -2887,7 +2851,8 @@ export class Session {
payload: {
status: "agent_create_failed",
requestId,
error: (error as Error)?.message ?? String(error),
error: wireError.message,
errorCode: wireError.code,
},
});
}
@@ -2897,7 +2862,7 @@ export class Session {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${error.message}`,
content: `Failed to create agent: ${wireError.message}`,
},
});
}
@@ -3055,6 +3020,8 @@ export class Session {
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktree(input, serviceOptions),
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
@@ -4145,11 +4112,13 @@ export class Session {
private async handleGitHubSearchRequest(
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
): Promise<void> {
const { cwd, query, limit, requestId } = msg;
const { cwd, query, limit, kinds, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit);
const result = await searchGitHubIssuesAndPrs(resolvedCwd, query, limit, this.github, {
kinds,
});
this.emit({
type: "github_search_response",
payload: {
@@ -4731,11 +4700,15 @@ export class Session {
if (!body) body = generated.body;
}
const result = await createPullRequest(cwd, {
title,
body,
base: msg.baseRef,
});
const result = await createPullRequest(
cwd,
{
title,
body,
base: msg.baseRef,
},
this.github,
);
this.emit({
type: "checkout_pr_create_response",
@@ -5968,7 +5941,7 @@ export class Session {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
if (existingWorkspace) {
return existingWorkspace;
return this.ensureWorkspaceRecordUnarchived(existingWorkspace);
}
const placement = await buildProjectPlacementForCwdStandalone({
@@ -6004,27 +5977,50 @@ export class Session {
return workspaceRecord;
}
private async registerPendingWorktreeWorkspace(options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
return registerPendingWorktreeWorkspaceSession(
{
buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
findWorkspaceByDirectory: (directory) => this.findWorkspaceByDirectory(directory),
projectRegistry: this.projectRegistry,
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
workspaceRegistry: this.workspaceRegistry,
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
},
options,
);
private async ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const project = await this.projectRegistry.get(workspace.projectId);
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
return workspace;
}
const timestamp = new Date().toISOString();
let unarchivedWorkspace = workspace;
if (workspace.archivedAt) {
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
await this.workspaceRegistry.upsert(unarchivedWorkspace);
}
if (project?.archivedAt) {
await this.projectRegistry.upsert({
...project,
archivedAt: null,
updatedAt: timestamp,
});
}
return unarchivedWorkspace;
}
private async createPaseoWorktree(
input: CreatePaseoWorktreeInput,
options?: {
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
},
): Promise<CreatePaseoWorktreeResult> {
const coreDeps = createWorktreeCoreDeps(this.github);
return createPaseoWorktree(input, {
...coreDeps,
...(options?.resolveRepositoryDefaultBranch
? { resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch }
: {}),
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
workspaceGitService: this.workspaceGitService,
primeWorkspaceGitWatchFingerprints: (workspace) =>
this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
broadcastWorkspaceUpdate: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
});
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
@@ -6498,13 +6494,9 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
@@ -1406,6 +1406,69 @@ describe("workspace aggregation", () => {
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
});
test("open_project_request unarchives an existing archived workspace and project", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = "/tmp/repo";
projects.set(
cwd,
createPersistedProjectRecord({
projectId: cwd,
rootPath: cwd,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
workspaces.set(
cwd,
createPersistedWorkspaceRecord({
workspaceId: cwd,
projectId: cwd,
cwd,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
session.emit = (message: any) => emitted.push(message);
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
await session.handleMessage({
type: "open_project_request",
cwd,
requestId: "req-open-unarchive",
});
expect(workspaces.get(cwd)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(cwd);
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
@@ -50,6 +50,7 @@ import {
buildAgentAttentionNotificationPayload,
findLatestPermissionRequest,
} from "../shared/agent-attention-notification.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
export type ExternalSocketMetadata = {
transport: "relay";
@@ -299,6 +300,7 @@ export class VoiceAssistantWebSocketServer {
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitServiceImpl;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
@@ -388,6 +390,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitServiceImpl,
github?: GitHubService,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -415,6 +418,7 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
@@ -742,6 +746,7 @@ export class VoiceAssistantWebSocketServer {
loopService: this.loopService,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
github: this.github,
workspaceGitService: this.workspaceGitService,
daemonConfigStore: this.daemonConfigStore,
mcpBaseUrl: this.mcpBaseUrl,
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import path from "node:path";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
@@ -139,11 +140,36 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: vi.fn(async () => []),
listIssues: vi.fn(async () => []),
getPullRequest: vi.fn(async () => ({
number: 1,
title: "PR",
url: "https://github.com/acme/repo/pull/1",
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: "feature",
labels: [],
})),
getPullRequestHeadRef: vi.fn(async () => "feature"),
getCurrentPullRequestStatus: vi.fn(async () => null),
createPullRequest: vi.fn(async () => ({
url: "https://github.com/acme/repo/pull/1",
number: 1,
})),
isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(),
};
}
function createService(options?: {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
resolveGhPath?: ReturnType<typeof vi.fn>;
github?: GitHubService;
resolveAbsoluteGitDir?: ReturnType<typeof vi.fn>;
hasOriginRemote?: ReturnType<typeof vi.fn>;
runGitFetch?: ReturnType<typeof vi.fn>;
@@ -168,7 +194,7 @@ function createService(options?: {
})),
getPullRequestStatus:
options?.getPullRequestStatus ?? vi.fn(async () => createPullRequestStatusResult()),
resolveGhPath: options?.resolveGhPath ?? vi.fn(async () => "/usr/bin/gh"),
github: options?.github ?? createGitHubServiceStub(),
resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"),
hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false),
runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}),
@@ -8,9 +8,9 @@ import {
getCheckoutStatus,
getPullRequestStatus,
hasOriginRemote,
resolveGhPath,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
@@ -78,7 +78,7 @@ interface WorkspaceGitServiceDependencies {
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getPullRequestStatus: typeof getPullRequestStatus;
resolveGhPath: typeof resolveGhPath;
github: GitHubService;
resolveAbsoluteGitDir: (cwd: string) => Promise<string | null>;
hasOriginRemote: (cwd: string) => Promise<boolean>;
runGitFetch: (cwd: string) => Promise<void>;
@@ -143,7 +143,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus,
getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat,
getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus,
resolveGhPath: options.deps?.resolveGhPath ?? resolveGhPath,
github: options.deps?.github ?? createGitHubService(),
resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir,
hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote,
runGitFetch: options.deps?.runGitFetch ?? runGitFetch,
@@ -792,7 +792,7 @@ async function loadWorkspaceGitRuntimeSnapshot(
now: Date,
deps: Pick<
WorkspaceGitServiceDependencies,
"getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "resolveGhPath"
"getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github"
>,
): Promise<WorkspaceGitRuntimeSnapshot> {
const checkoutStatus = await deps.getCheckoutStatus(cwd, context);
@@ -833,7 +833,7 @@ async function loadGitHubSnapshot(options: {
cwd: string;
remoteUrl: string | null;
now: Date;
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "resolveGhPath">;
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
if (!hasGitHubRemoteUrl(options.remoteUrl)) {
return {
@@ -845,7 +845,7 @@ async function loadGitHubSnapshot(options: {
}
try {
await options.deps.resolveGhPath();
await options.deps.github.isAuthenticated({ cwd: options.cwd });
} catch {
return {
featuresEnabled: false,
@@ -856,7 +856,7 @@ async function loadGitHubSnapshot(options: {
}
try {
const result = await options.deps.getPullRequestStatus(options.cwd);
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github);
return {
featuresEnabled: true,
pullRequest: result.status,
@@ -5,13 +5,45 @@ import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
spawnWorktreeScripts,
} from "./worktree-bootstrap.js";
import { runAsyncWorktreeBootstrap, spawnWorktreeScripts } from "./worktree-bootstrap.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import {
createWorktree as createWorktreePrimitive,
type WorktreeConfig,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
interface CreateAgentWorktreeTestOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
interface CreateAgentWorktreeTestResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
async function createBootstrapWorktreeForTest(
options: CreateAgentWorktreeTestOptions,
): Promise<CreateAgentWorktreeTestResult> {
const worktree = await createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: false,
paseoHome: options.paseoHome,
});
return { worktree, shouldBootstrap: true };
}
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
@@ -62,7 +94,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
@@ -170,7 +202,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-live-failure",
baseBranch: "main",
@@ -221,7 +253,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-large-output",
baseBranch: "main",
@@ -280,7 +312,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-carriage-return",
baseBranch: "main",
@@ -336,7 +368,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-terminal-readiness",
baseBranch: "main",
@@ -428,7 +460,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
const worktreeBootstrap = await createBootstrapWorktreeForTest({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
@@ -510,12 +542,16 @@ describe("runAsyncWorktreeBootstrap", () => {
function createStubTerminalManager(
createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }>,
) {
): TerminalManager {
return {
async getTerminals() {
return [];
},
async createTerminal(options: { cwd: string; name?: string; env?: Record<string, string> }) {
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}): Promise<TerminalSession> {
createTerminalCalls.push(options);
return {
id: "term-service",
@@ -536,6 +572,7 @@ describe("runAsyncWorktreeBootstrap", () => {
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
killAndWait: async () => {},
};
},
registerCwdEnv() {},
@@ -543,6 +580,7 @@ describe("runAsyncWorktreeBootstrap", () => {
return undefined;
},
killTerminal() {},
async killTerminalAndWait() {},
listDirectories() {
return [];
},
@@ -582,7 +620,7 @@ describe("runAsyncWorktreeBootstrap", () => {
daemonPort: null,
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
expect(results).toHaveLength(1);
@@ -629,7 +667,7 @@ describe("runAsyncWorktreeBootstrap", () => {
daemonPort: 6767,
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
expect(results).toHaveLength(1);
@@ -687,7 +725,7 @@ describe("runAsyncWorktreeBootstrap", () => {
daemonListenHost: "100.64.0.20",
routeStore,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls) as any,
terminalManager: createStubTerminalManager(createTerminalCalls),
});
expect(createTerminalCalls).toHaveLength(1);
@@ -1,16 +1,12 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import { sep } from "node:path";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
createWorktree,
getScriptConfigs,
getWorktreeTerminalSpecs,
isServiceScript,
listPaseoWorktrees,
processCarriageReturns,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
@@ -41,26 +37,9 @@ export interface RunAsyncWorktreeBootstrapOptions {
logger?: Logger;
}
export interface CreateAgentWorktreeOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
export interface CreateAgentWorktreeResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
type MiddleTruncationAccumulator = {
totalBytes: number;
@@ -174,56 +153,6 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
};
}
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions,
): Promise<CreateAgentWorktreeResult> {
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
if (existingWorktree) {
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
return {
worktree: {
branchName,
worktreePath: existingWorktree.path,
},
shouldBootstrap: false,
};
}
const createdWorktree = await createWorktree({
branchName: options.branchName,
cwd: options.cwd,
baseBranch: options.baseBranch,
worktreeSlug: options.worktreeSlug,
runSetup: false,
paseoHome: options.paseoHome,
});
return {
worktree: createdWorktree,
shouldBootstrap: true,
};
}
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
const worktrees = await listPaseoWorktrees({
cwd: options.cwd,
paseoHome: options.paseoHome,
});
const slugSuffix = `${sep}${options.worktreeSlug}`;
return worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
}
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (!branchName) {
throw new Error(`Unable to resolve branch for existing worktree: ${worktreePath}`);
}
return branchName;
}
function formatDurationMs(durationMs: number): string {
return `${(durationMs / 1000).toFixed(2)}s`;
}
@@ -0,0 +1,455 @@
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test, afterEach } from "vitest";
import type { GitHubService } from "../services/github-service.js";
import { UnknownBranchError } from "../utils/worktree.js";
import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js";
function createGitHubServiceStub(): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createCoreDeps(options?: {
github?: GitHubService;
generateBranchName?: (seed: string | undefined) => string;
}) {
return {
github: options?.github ?? createGitHubServiceStub(),
resolveRepositoryDefaultBranch: async () => "main",
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
};
}
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
const repoDir = path.join(tempDir, "repo");
const paseoHome = path.join(tempDir, ".paseo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitRepoWithDevBranch(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'dev branch'", {
cwd: repoDir,
stdio: "pipe",
});
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const { tempDir, repoDir, paseoHome } = createGitRepo();
const featureBranch = "feature/review-pr";
execSync(`git checkout -b ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "review branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'review branch'", {
cwd: repoDir,
stdio: "pipe",
});
const featureSha = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" })
.toString()
.trim();
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
execSync(`git branch -D ${JSON.stringify(featureBranch)}`, { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe",
});
execSync(
`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`,
{ stdio: "pipe" },
);
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir, paseoHome };
}
describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("creates the legacy RPC branch-off worktree from the repo default branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "legacy-rpc",
attachments: [],
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "legacy-rpc",
});
expect(result.created).toBe(true);
expect(result.worktree.branchName).toBe("legacy-rpc");
expect(existsSync(result.worktree.worktreePath)).toBe(true);
});
test("checks out the legacy RPC GitHub PR attachment branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "review-pr-123",
paseoHome,
runSetup: false,
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "feature/review-pr",
},
],
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/review-pr",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("uses the PR head ref as the default slug when no slug is supplied", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
paseoHome,
runSetup: false,
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "feature/review-pr",
},
],
},
createCoreDeps(),
);
expect(path.basename(result.worktree.worktreePath)).toBe("feature-review-pr");
expect(result.worktree.branchName).toBe("feature/review-pr");
});
test("creates the MCP standalone worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "mcp-standalone",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "mcp-standalone",
});
expect(result.worktree.branchName).toBe("mcp-standalone");
});
test("branches off an explicit refName base", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const devTip = execSync("git rev-parse dev", { cwd: repoDir, stdio: "pipe" }).toString().trim();
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "from-dev",
action: "branch-off",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const mergeBase = execSync(`git merge-base HEAD ${JSON.stringify(devTip)}`, {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "dev",
newBranchName: "from-dev",
});
expect(mergeBase).toBe(devTip);
});
test("checks out an explicit existing branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithDevBranch();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "dev",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
const branch = execSync("git branch --show-current", {
cwd: result.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(result.intent).toEqual({
kind: "checkout-branch",
branchName: "dev",
});
expect(branch).toBe("dev");
});
test("checks out an explicit GitHub PR target", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "pr-123",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("pr-123");
});
test("throws a typed error for an unknown checkout branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
await expect(
createCoreWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "missing-branch",
paseoHome,
runSetup: false,
},
createCoreDeps(),
),
).rejects.toBeInstanceOf(UnknownBranchError);
});
test("creates the agent-create worktree input shape", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "agent-worktree",
paseoHome,
runSetup: false,
},
createCoreDeps(),
);
expect(result.intent).toEqual({
kind: "branch-off",
baseBranch: "main",
newBranchName: "agent-worktree",
});
expect(result.worktree.branchName).toBe("agent-worktree");
});
test("reuses an existing branch-off worktree for the same slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const first = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
const second = await createCoreWorktree(
{ cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false },
deps,
);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
test("reuses an existing GitHub PR worktree for the resolved slug", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const deps = createCoreDeps();
const input = {
cwd: repoDir,
paseoHome,
runSetup: false,
attachments: [
{
type: "github_pr" as const,
mimeType: "application/github-pr" as const,
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "feature/review-pr",
},
],
};
const first = await createCoreWorktree(input, deps);
const second = await createCoreWorktree(input, deps);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.worktree).toEqual(first.worktree);
});
test("uses an injectable GitHubService dependency for missing PR head refs", async () => {
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const headRefLookups: Array<{ cwd: string; number: number }> = [];
const github: GitHubService = {
...createGitHubServiceStub(),
getPullRequestHeadRef: async ({ cwd, number }) => {
headRefLookups.push({ cwd, number });
return "feature/from-service";
},
};
const result = await createCoreWorktree(
{
cwd: repoDir,
worktreeSlug: "stubbed-github",
paseoHome,
runSetup: false,
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Review branch",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
},
],
},
createCoreDeps({ github }),
);
expect(headRefLookups).toEqual([{ cwd: repoDir, number: 123 }]);
expect(result.intent).toEqual({
kind: "checkout-github-pr",
githubPrNumber: 123,
headRef: "feature/from-service",
baseRefName: "main",
});
expect(result.worktree.branchName).toBe("feature/from-service");
});
test("keeps direct createWorktree calls isolated to the core layer", () => {
const serverSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
// Keep this literal in the test file so the invariant proves tests are allowed to inspect createWorktree(.
const output = execSync(
"rg --files-with-matches 'createWorktree\\(' . --glob '!**/utils/worktree.ts' --glob '!**/*.test.ts'",
{
cwd: serverSrc,
stdio: "pipe",
},
)
.toString()
.trim()
.split("\n")
.map((line) => line.replace(/^\.\//, ""))
.filter(Boolean)
.sort();
expect(output).toEqual(["server/worktree-core.ts"]);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { v4 as uuidv4 } from "uuid";
import type { GitHubService } from "../services/github-service.js";
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import {
createWorktree,
resolveExistingWorktreeForSlug,
slugify,
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
import {
resolveWorktreeCreationIntent,
type ResolveWorktreeCreationIntentInput,
type WorktreeCreationIntent,
} from "./resolve-worktree-creation-intent.js";
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
cwd: string;
paseoHome?: string;
runSetup?: boolean;
}
export interface CreateWorktreeCoreDeps {
github: GitHubService;
resolveRepositoryDefaultBranch: (repoRoot: string) => Promise<string>;
generateBranchName: (seed: string | undefined) => string;
}
export interface CreateWorktreeCoreResult {
worktree: WorktreeConfig;
intent: WorktreeCreationIntent;
repoRoot: string;
created: boolean;
}
export async function createWorktreeCore(
input: CreateWorktreeCoreInput,
deps: CreateWorktreeCoreDeps,
): Promise<CreateWorktreeCoreResult> {
const repoRoot = await resolveWorktreeRepoRoot(input);
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
const intent = await resolveWorktreeCreationIntent(
{ ...input, worktreeSlug: requestedSlug },
repoRoot,
deps,
);
let normalizedSlug: string;
switch (intent.kind) {
case "branch-off": {
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName));
break;
}
case "checkout-branch": {
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName));
break;
}
case "checkout-github-pr": {
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.headRef));
break;
}
}
const existingWorktree = await resolveExistingWorktreeForSlug({
slug: normalizedSlug,
repoRoot,
paseoHome: input.paseoHome,
});
if (existingWorktree) {
return { worktree: existingWorktree, intent, repoRoot, created: false };
}
return {
worktree: await createWorktree({
cwd: repoRoot,
worktreeSlug: normalizedSlug,
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
}),
intent,
repoRoot,
created: true,
};
}
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
return {
github,
resolveRepositoryDefaultBranch: resolveDefaultBranch,
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
};
}
async function resolveDefaultBranch(repoRoot: string): Promise<string> {
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
return baseBranch;
}
async function resolveWorktreeRepoRoot(input: CreateWorktreeCoreInput): Promise<string> {
const checkout = await getCheckoutStatus(
input.cwd,
input.paseoHome ? { paseoHome: input.paseoHome } : undefined,
);
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
return checkout.isPaseoOwnedWorktree
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? input.cwd)
: (checkout.repoRoot ?? input.cwd);
}
function validateWorktreeSlug(slug: string): string {
const validation = validateBranchSlug(slug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
return slug;
}
@@ -0,0 +1,50 @@
import {
ConflictingGitHubPullRequestIntentError,
MissingCheckoutTargetError,
} from "./resolve-worktree-creation-intent.js";
import { BranchAlreadyCheckedOutError, UnknownBranchError } from "../utils/worktree.js";
export type WorktreeWireErrorCode =
| "branch_already_checked_out"
| "missing_checkout_target"
| "conflicting_github_pull_request_intent"
| "unknown_branch"
| "unknown";
export interface WorktreeWireError {
code: WorktreeWireErrorCode;
message: string;
}
export class WorktreeRequestError extends Error {
readonly code: WorktreeWireErrorCode;
constructor(error: WorktreeWireError) {
super(error.message);
this.name = "WorktreeRequestError";
this.code = error.code;
}
}
export function toWorktreeWireError(error: unknown): WorktreeWireError {
if (error instanceof BranchAlreadyCheckedOutError) {
return { code: "branch_already_checked_out", message: error.message };
}
if (error instanceof MissingCheckoutTargetError) {
return { code: "missing_checkout_target", message: error.message };
}
if (error instanceof ConflictingGitHubPullRequestIntentError) {
return { code: "conflicting_github_pull_request_intent", message: error.message };
}
if (error instanceof UnknownBranchError) {
return { code: "unknown_branch", message: error.message };
}
if (error instanceof Error) {
return { code: "unknown", message: error.message };
}
return { code: "unknown", message: String(error) };
}
export function toWorktreeRequestError(error: unknown): WorktreeRequestError {
return new WorktreeRequestError(toWorktreeWireError(error));
}
@@ -10,10 +10,10 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import type { SessionOutboundMessage } from "./messages.js";
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
import { ScriptRouteStore } from "./script-proxy.js";
import * as worktreeBootstrap from "./worktree-bootstrap.js";
import {
archivePaseoWorktree,
buildAgentSessionConfig,
@@ -22,14 +22,84 @@ import {
handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest,
} from "./worktree-session.js";
import { createWorktree } from "../utils/worktree.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { GitHubService } from "../services/github-service.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
function createLogger() {
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
function createLogger(): Logger {
const logger = pino({ level: "silent" });
vi.spyOn(logger, "info").mockImplementation(() => undefined);
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
vi.spyOn(logger, "error").mockImplementation(() => undefined);
return logger;
}
function createGitHubServiceStub(): GitHubService {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async ({ number }) => ({
number,
title: `PR ${number}`,
url: `https://github.com/acme/repo/pull/${number}`,
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: `pr-${number}`,
labels: [],
}),
getPullRequestHeadRef: async ({ number }) => `pr-${number}`,
getCurrentPullRequestStatus: async () => null,
createPullRequest: async () => ({
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createTerminalManagerStub(options?: {
@@ -37,7 +107,7 @@ function createTerminalManagerStub(options?: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => Promise<any>;
}) => Promise<TerminalSession>;
}) {
const terminals: Array<{
id: string;
@@ -59,18 +129,29 @@ function createTerminalManagerStub(options?: {
const sent: string[] = [];
const terminal = {
id: `terminal-${terminals.length + 1}`,
name: input.name ?? "Terminal",
cwd: input.cwd,
getState: () => ({
rows: 1,
cols: 1,
scrollback: [[{ char: "$" }]],
grid: [],
cursor: { row: 0, col: 0 },
}),
subscribe: () => () => {},
onExit: () => () => {},
onTitleChange: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
}
},
};
kill: () => {},
killAndWait: async () => {},
getSize: () => ({ rows: 1, cols: 1 }),
getTitle: () => undefined,
getExitInfo: () => null,
} satisfies TerminalSession;
terminals.push({
id: terminal.id,
cwd: input.cwd,
@@ -81,7 +162,126 @@ function createTerminalManagerStub(options?: {
return terminal;
},
),
} as any,
getTerminals: vi.fn(async () => []),
getTerminal: vi.fn(() => undefined),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(async () => {}),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => () => {}),
} satisfies TerminalManager,
};
}
function createWorkspaceDescriptor(input: {
workspace: PersistedWorkspaceRecord;
repoDir: string;
}): WorkspaceDescriptorPayload {
return {
id: input.workspace.workspaceId,
projectId: input.workspace.projectId,
projectDisplayName: path.basename(input.repoDir),
projectRootPath: input.repoDir,
workspaceDirectory: input.workspace.cwd,
workspaceKind: "worktree",
projectKind: "git",
name: input.workspace.displayName,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
gitRuntime: null,
githubRuntime: null,
};
}
function createPaseoWorktreeForTest(options: {
paseoHome: string;
events?: string[];
}): CreatePaseoWorktreeFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createLogger(),
paseoHome: options.paseoHome,
deps: {
github: createGitHubServiceStub(),
},
});
return (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
return createPaseoWorktreeService(input, {
...coreDeps,
...(serviceOptions?.resolveRepositoryDefaultBranch
? { resolveRepositoryDefaultBranch: serviceOptions.resolveRepositoryDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
options.events?.push(`project:${record.projectId}`);
projects.set(record.projectId, record);
},
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
options.events?.push(`workspace:${record.workspaceId}`);
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async (workspace) => {
options.events?.push(`prime:${workspace.workspaceId}`);
},
broadcastWorkspaceUpdate: async (workspaceId) => {
options.events?.push(`broadcast:${workspaceId}`);
},
});
};
}
function createManagedAgentForArchive(input: { id: string; cwd: string }): ManagedAgent {
const now = new Date();
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
config: { provider: "codex", cwd: input.cwd },
createdAt: now,
updatedAt: now,
availableModes: [],
currentModeId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: null,
historyPrimed: false,
lastUserMessageAt: null,
attention: { requiresAttention: false },
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
labels: {},
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
};
}
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
return {
list: async (): Promise<StoredAgentRecord[]> => [],
remove: vi.fn(async () => {}),
};
}
@@ -155,7 +355,7 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-no-setup",
cwd: repoDir,
baseBranch: "main",
@@ -254,7 +454,7 @@ describe("runWorktreeSetupInBackground", () => {
});
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "broken-feature",
cwd: repoDir,
baseBranch: "main",
@@ -324,7 +524,7 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-running-setup",
cwd: repoDir,
baseBranch: "main",
@@ -442,7 +642,7 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const existingWorktree = await createWorktree({
const existingWorktree = await createLegacyWorktreeForTest({
branchName: "reused-worktree",
cwd: repoDir,
baseBranch: "main",
@@ -533,7 +733,7 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-service-failure",
cwd: repoDir,
baseBranch: "main",
@@ -619,7 +819,7 @@ describe("runWorktreeSetupInBackground", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-socket-mode",
cwd: repoDir,
baseBranch: "main",
@@ -692,7 +892,6 @@ describe("runWorktreeSetupInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: snapshots,
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
@@ -728,7 +927,6 @@ describe("runWorktreeSetupInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
@@ -769,32 +967,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
{
paseoHome,
describeWorkspaceRecord: async (workspace) =>
({
id: String(workspace.id),
projectId: String(workspace.projectId),
projectDisplayName: "repo",
projectRootPath: repoDir,
workspaceDirectory: workspace.directory,
workspaceKind: "worktree",
projectKind: "git",
name: workspace.displayName,
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
}) as any,
createWorkspaceDescriptor({ workspace, repoDir }),
emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: async ({ worktreePath, branchName }) =>
({
id: "ws-pr-worktree",
projectId: "proj-pr-worktree",
directory: worktreePath,
displayName: branchName,
kind: "worktree",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
archivedAt: null,
}) as any,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
sessionLogger: logger,
runWorktreeSetupInBackground: async () => {},
},
@@ -845,11 +1020,16 @@ describe("handleCreatePaseoWorktreeRequest", () => {
test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => {
const { tempDir, repoDir } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const result = await buildAgentSessionConfig(
{
paseoHome: path.join(tempDir, ".paseo"),
sessionLogger: createLogger(),
createPaseoWorktree: createPaseoWorktreeForTest({
paseoHome: path.join(tempDir, ".paseo"),
events,
}),
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
@@ -881,6 +1061,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
const branch = execSync("git branch --show-current", {
cwd: result.sessionConfig.cwd,
@@ -890,14 +1072,71 @@ describe("handleCreatePaseoWorktreeRequest", () => {
.trim();
expect(branch).toBe("feature/review-pr");
});
test("buildAgentSessionConfig uses the normalized new branch name as the worktree slug fallback", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const result = await buildAgentSessionConfig(
{
paseoHome,
sessionLogger: createLogger(),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
createBranchFromBase: async () => {
throw new Error("should not create a branch outside the worktree service");
},
},
{
provider: "codex",
cwd: repoDir,
},
{
createWorktree: true,
createNewBranch: true,
newBranchName: "feature-x",
},
);
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature-x");
expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("feature-x");
});
test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const resolveRepositoryDefaultBranch = vi.fn(async () => "main");
const result = await createPaseoWorktreeForTest({ paseoHome })(
{
cwd: repoDir,
worktreeSlug: "resolver-feature",
action: "branch-off",
runSetup: false,
paseoHome,
},
{ resolveRepositoryDefaultBranch },
);
expect(result.intent).toMatchObject({
kind: "branch-off",
baseBranch: "main",
newBranchName: "resolver-feature",
});
expect(resolveRepositoryDefaultBranch).toHaveBeenCalledWith(repoDir);
});
});
describe("handleCreatePaseoWorktreeRequest", () => {
test("invokes worktree creation once for a create request", async () => {
test("registers a pending workspace and emits a successful create response", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = [];
const createAgentWorktreeSpy = vi.spyOn(worktreeBootstrap, "createAgentWorktree");
const events: string[] = [];
try {
await handleCreatePaseoWorktreeRequest(
@@ -905,10 +1144,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: vi.fn(async (options) => ({
workspaceId: "ws-single-call",
projectId: "proj-single-call",
})),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
describeWorkspaceRecord: vi.fn(async (workspace) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
@@ -930,7 +1166,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
},
);
expect(createAgentWorktreeSpy).toHaveBeenCalledTimes(1);
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
const response = emitted.find(
(
message,
@@ -939,7 +1176,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
expect(response?.payload.error).toBeNull();
} finally {
createAgentWorktreeSpy.mockRestore();
rmSync(tempDir, { recursive: true, force: true });
}
});
@@ -957,25 +1193,15 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: vi.fn(async (options) => {
expect(existsSync(options.worktreePath)).toBe(true);
registeredWorktreePath = options.worktreePath;
return {
workspaceId: "ws-response-after-create",
projectId: "proj-response-after-create",
} as any;
}),
describeWorkspaceRecord: vi.fn(async (workspace) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: path.basename(repoDir),
projectRootPath: repoDir,
projectKind: "git",
workspaceKind: "worktree",
name: "response-after-create",
status: "done",
activityAt: null,
})),
createPaseoWorktree: async (input) => {
const result = await createPaseoWorktreeForTest({ paseoHome })(input);
expect(existsSync(result.worktree.worktreePath)).toBe(true);
registeredWorktreePath = result.worktree.worktreePath;
return result;
},
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
),
runWorktreeSetupInBackground: backgroundWork,
},
{
@@ -1011,6 +1237,87 @@ describe("handleCreatePaseoWorktreeRequest", () => {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("emits a machine-readable error code for invalid worktree intent", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = [];
try {
await handleCreatePaseoWorktreeRequest(
{
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
),
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
type: "create_paseo_worktree_request",
cwd: repoDir,
action: "checkout",
attachments: [],
requestId: "req-missing-target",
},
);
const response = emitted.find(
(
message,
): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.workspace).toBeNull();
expect(response?.payload.error).toBe('action "checkout" requires refName or githubPrNumber');
expect(response?.payload.errorCode).toBe("missing_checkout_target");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("emits a machine-readable error code for unknown checkout branches", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = [];
try {
await handleCreatePaseoWorktreeRequest(
{
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
),
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
type: "create_paseo_worktree_request",
cwd: repoDir,
action: "checkout",
refName: "missing-branch",
attachments: [],
requestId: "req-unknown-branch",
},
);
const response = emitted.find(
(
message,
): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.workspace).toBeNull();
expect(response?.payload.error).toBe("Unknown branch: missing-branch");
expect(response?.payload.errorCode).toBe("unknown_branch");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("archivePaseoWorktree", () => {
@@ -1038,7 +1345,7 @@ describe("archivePaseoWorktree", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "archive-parallel",
cwd: repoDir,
baseBranch: "main",
@@ -1066,15 +1373,12 @@ describe("archivePaseoWorktree", () => {
paseoHome,
agentManager: {
listAgents: () => [
{ id: "agent-1", cwd: created.worktreePath } as any,
{ id: "agent-2", cwd: created.worktreePath } as any,
createManagedAgentForArchive({ id: "agent-1", cwd: created.worktreePath }),
createManagedAgentForArchive({ id: "agent-2", cwd: created.worktreePath }),
],
closeAgent: closeAgentSpy,
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
@@ -1107,7 +1411,7 @@ describe("archivePaseoWorktree", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "archive-terminal-throws",
cwd: repoDir,
baseBranch: "main",
@@ -1127,10 +1431,7 @@ describe("archivePaseoWorktree", () => {
listAgents: () => [],
closeAgent: vi.fn(async () => {}),
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: vi.fn(),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
@@ -1154,7 +1455,7 @@ describe("archivePaseoWorktree", () => {
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "archive-orphan",
cwd: repoDir,
baseBranch: "main",
@@ -1178,10 +1479,7 @@ describe("archivePaseoWorktree", () => {
listAgents: () => [],
closeAgent: vi.fn(async () => {}),
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
+109 -297
View File
@@ -1,36 +1,23 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import {
type AgentAttachment,
type GitSetupOptions,
type ProjectPlacementPayload,
type SessionInboundMessage,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
} from "./messages.js";
import { findGitHubPrAttachment } from "./agent/prompt-attachments.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
deriveWorkspaceId,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import {
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
createAgentWorktree,
createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults,
spawnWorktreeScripts,
@@ -38,14 +25,9 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import {
getCheckoutStatus,
getCurrentBranch,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js";
import { getCheckoutStatus, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
deletePaseoWorktree,
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
@@ -59,19 +41,26 @@ import {
type WorktreeSetupCommandResult,
WorktreeSetupError,
} from "../utils/worktree.js";
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { toCheckoutError } from "./checkout-git-utils.js";
import type {
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const execFileAsync = promisify(execFile);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
export type NormalizedGitOptions = {
export interface NormalizedGitOptions {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
requestedWorktreeSlug?: string;
refName?: string;
action?: "branch-off" | "checkout";
githubPrNumber?: number;
}
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
@@ -79,6 +68,12 @@ type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
workspaceGitService?: WorkspaceGitService;
createPaseoWorktree: (
input: CreatePaseoWorktreeInput,
options?: {
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
},
) => Promise<CreatePaseoWorktreeResult>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
createBranchFromBase: (params: {
cwd: string;
@@ -99,29 +94,6 @@ type ArchivePaseoWorktreeDependencies = {
sessionLogger?: Logger;
};
type RegisterPendingWorktreeWorkspaceDependencies = {
buildPersistedProjectRecord: (input: {
workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedProjectRecord;
buildPersistedWorkspaceRecord: (input: {
workspaceId: string;
cwd: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "list">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
@@ -144,17 +116,11 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
workspaceGitService?: WorkspaceGitService;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
registerPendingWorktreeWorkspace: (options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
@@ -199,73 +165,38 @@ export async function buildAgentSessionConfig(
}
if (normalized.createWorktree) {
const githubPrAttachment = findGitHubPrAttachment(attachments);
if (githubPrAttachment) {
const baseBranch =
githubPrAttachment.baseRefName?.trim() ||
normalized.baseBranch ||
(await resolveGitCreateBaseBranch(
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
const worktreeSlug =
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
const createdWorktree = await createGitHubPrWorktree({
cwd,
branchName: resolveGitHubPrBranchName(githubPrAttachment),
baseBranch,
prNumber: githubPrAttachment.number,
worktreeSlug,
paseoHome: dependencies.paseoHome,
});
cwd = createdWorktree.worktreePath;
worktreeBootstrap = {
worktree: createdWorktree,
shouldBootstrap: true,
};
return {
sessionConfig: {
...config,
cwd,
},
worktreeBootstrap,
};
}
let targetBranch: string;
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
targetBranch = (await getCurrentBranch(cwd)) ?? "";
}
if (!targetBranch) {
throw new Error("A branch name is required when creating a worktree.");
}
dependencies.sessionLogger.info(
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
{ worktreeSlug: normalized.requestedWorktreeSlug },
"Creating worktree through createWorktreeCore",
);
const baseBranch =
normalized.baseBranch ??
(await resolveGitCreateBaseBranch(
const createdWorktree = await dependencies.createPaseoWorktree(
{
cwd,
dependencies.workspaceGitService,
dependencies.paseoHome,
));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
paseoHome: dependencies.paseoHome,
});
worktreeSlug: normalized.worktreeSlug,
refName: normalized.refName,
action: normalized.action,
githubPrNumber: normalized.githubPrNumber,
attachments,
runSetup: false,
paseoHome: dependencies.paseoHome,
},
{
resolveRepositoryDefaultBranch: normalized.baseBranch
? async () => normalized.baseBranch!
: (repoRoot) =>
resolveGitCreateBaseBranch(
repoRoot,
dependencies.workspaceGitService,
dependencies.paseoHome,
),
},
);
cwd = createdWorktree.worktree.worktreePath;
worktreeBootstrap = createdWorktree;
worktreeBootstrap = {
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
};
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ??
@@ -314,11 +245,20 @@ export function normalizeGitOptions(
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
const requestedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) : undefined;
const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName;
const refName = merged.refName?.trim() || undefined;
const action = merged.action;
const githubPrNumber = merged.githubPrNumber;
if (!createWorktree && !createNewBranch && !baseBranch) {
if (
!createWorktree &&
!createNewBranch &&
!baseBranch &&
!refName &&
!action &&
!githubPrNumber
) {
return null;
}
@@ -349,6 +289,10 @@ export function normalizeGitOptions(
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
requestedWorktreeSlug,
refName,
action,
githubPrNumber,
};
}
@@ -390,56 +334,6 @@ export async function resolveGitCreateBaseBranch(
return baseBranch;
}
function resolveGitHubPrBranchName(
attachment: Extract<AgentAttachment, { type: "github_pr" }>,
): string {
const trimmed = attachment.headRefName?.trim();
return trimmed && trimmed.length > 0 ? trimmed : `pr-${attachment.number}`;
}
async function fetchGitHubPrBranch(params: {
cwd: string;
branchName: string;
prNumber: number;
}): Promise<void> {
await execFileAsync(
"git",
[
"fetch",
"origin",
`refs/pull/${params.prNumber}/head:refs/heads/${params.branchName}`,
"--force",
],
{ cwd: params.cwd },
);
}
async function createGitHubPrWorktree(params: {
cwd: string;
branchName: string;
baseBranch: string;
prNumber: number;
worktreeSlug: string;
paseoHome?: string;
}): Promise<WorktreeConfig> {
assertSafeGitRef(params.branchName, "github pr branch");
await computeWorktreePath(params.cwd, params.worktreeSlug, params.paseoHome);
await fetchGitHubPrBranch({
cwd: params.cwd,
branchName: params.branchName,
prNumber: params.prNumber,
});
const worktreePath = await computeWorktreePath(params.cwd, params.worktreeSlug, params.paseoHome);
await execFileAsync("git", ["worktree", "add", worktreePath, params.branchName], {
cwd: params.cwd,
});
writePaseoWorktreeMetadata(worktreePath, { baseRefName: params.baseBranch });
return {
branchName: params.branchName,
worktreePath,
};
}
export async function handlePaseoWorktreeListRequest(
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
@@ -515,7 +409,15 @@ export async function archivePaseoWorktree(
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
}
const storedRecords = await dependencies.agentStorage.list().catch(() => []);
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath },
"Failed to list stored agents during worktree archive; continuing",
);
}
const matchingStoredRecords = storedRecords.filter((record) =>
dependencies.isPathWithinRoot(targetPath, record.cwd),
);
@@ -550,11 +452,23 @@ export async function archivePaseoWorktree(
// Agent storage removal runs after closeAgent so file handles on the agent
// state file are released; still allSettled so a single bad record can't
// derail the rest.
await Promise.allSettled(
Array.from(agentIdsToRemoveFromStorage).map((agentId) =>
dependencies.agentStorage.remove(agentId),
),
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
const storageRemovalResults = await Promise.allSettled(
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
);
storageRemovalResults.forEach((result, index) => {
if (result.status === "fulfilled") {
return;
}
dependencies.sessionLogger?.warn(
{
err: result.reason,
agentId: agentIdsToRemove[index],
targetPath,
},
"Failed to remove archived worktree agent from storage; continuing",
);
});
await deletePaseoWorktree({
cwd: options.repoRoot,
@@ -669,126 +583,23 @@ export async function handlePaseoWorktreeArchiveRequest(
}
}
export async function registerPendingWorktreeWorkspace(
dependencies: RegisterPendingWorktreeWorkspaceDependencies,
options: {
repoRoot: string;
worktreePath: string;
branchName: string;
},
): Promise<PersistedWorkspaceRecord> {
const normalizedWorktreePath = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: normalizedWorktreePath,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
worktreeRoot: options.worktreePath,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const workspaceId = deriveWorkspaceId(normalizedWorktreePath, placement.checkout);
const now = new Date().toISOString();
const existingWorkspace = await dependencies.findWorkspaceByDirectory(normalizedWorktreePath);
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId,
cwd: normalizedWorktreePath,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
workspaceId,
cwd: normalizedWorktreePath,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(normalizedWorktreePath, { isGit: true });
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return (await dependencies.workspaceRegistry.get(nextWorkspaceRecord.workspaceId))!;
}
export async function handleCreatePaseoWorktreeRequest(
dependencies: HandleCreatePaseoWorktreeRequestDependencies,
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
let repoRoot = request.cwd;
if (dependencies.workspaceGitService) {
const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd);
if (!snapshot.git.isGit) {
throw new Error("Create worktree requires a git repository");
}
repoRoot = snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd)
: (snapshot.git.repoRoot ?? request.cwd);
} else {
const checkout = await getCheckoutStatus(
request.cwd,
dependencies.paseoHome ? { paseoHome: dependencies.paseoHome } : undefined,
);
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
repoRoot = checkout.isPaseoOwnedWorktree
? (checkout.mainRepoRoot ?? checkout.repoRoot ?? request.cwd)
: (checkout.repoRoot ?? request.cwd);
}
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
const validation = validateBranchSlug(normalizedSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
const githubPrAttachment = findGitHubPrAttachment(request.attachments);
const createdWorktree = githubPrAttachment
? {
worktree: await createGitHubPrWorktree({
cwd: repoRoot,
branchName: resolveGitHubPrBranchName(githubPrAttachment),
baseBranch: githubPrAttachment.baseRefName?.trim() || baseBranch,
prNumber: githubPrAttachment.number,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
}),
shouldBootstrap: true,
}
: await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath: createdWorktree.worktree.worktreePath,
branchName: createdWorktree.worktree.branchName,
const createdWorktree = await dependencies.createPaseoWorktree({
cwd: request.cwd,
worktreeSlug: request.worktreeSlug,
refName: request.refName,
action: request.action,
githubPrNumber: request.githubPrNumber,
attachments: request.attachments,
runSetup: false,
paseoHome: dependencies.paseoHome,
});
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
@@ -803,15 +614,15 @@ export async function handleCreatePaseoWorktreeRequest(
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.shouldBootstrap,
slug: normalizedSlug,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
const wireError = toWorktreeWireError(error);
dependencies.sessionLogger.error(
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
"Failed to create worktree",
@@ -820,7 +631,8 @@ export async function handleCreatePaseoWorktreeRequest(
type: "create_paseo_worktree_response",
payload: {
workspace: null,
error: message,
error: wireError.message,
errorCode: wireError.code,
setupTerminalId: null,
requestId: request.requestId,
},
@@ -0,0 +1,231 @@
import { describe, expect, it } from "vitest";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
createGitHubService,
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
} from "./github-service.js";
interface RunnerCall {
args: string[];
cwd: string;
}
interface TestRunner {
calls: RunnerCall[];
runner: GitHubCommandRunner;
resolveNext: (stdout: string) => void;
}
function createRunner(stdoutByCall: string[]): TestRunner {
const calls: RunnerCall[] = [];
return {
calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
const stdout = stdoutByCall.shift() ?? "[]";
return { stdout, stderr: "" };
},
resolveNext: () => {},
};
}
function createDeferredRunner(): TestRunner {
const calls: RunnerCall[] = [];
let resolveNext: ((stdout: string) => void) | null = null;
return {
calls,
runner: (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
return new Promise((resolve) => {
resolveNext = (stdout: string) => resolve({ stdout, stderr: "" });
});
},
resolveNext: (stdout: string) => {
if (!resolveNext) {
throw new Error("No runner call is waiting for resolution.");
}
resolveNext(stdout);
},
};
}
function pullRequestJson(title: string): string {
return JSON.stringify([
{
number: 123,
title,
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
baseRefName: "main",
headRefName: "feature",
labels: [{ name: "bug" }],
},
]);
}
describe("GitHubService", () => {
it("returns cached results for identical calls within the TTL", async () => {
const runner = createRunner([pullRequestJson("First result")]);
const service = createGitHubService({
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const first = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
const second = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
expect(first).toEqual(second);
expect(first[0]?.title).toBe("First result");
expect(runner.calls).toHaveLength(1);
});
it("refreshes cached results after the TTL expires", async () => {
let now = 100;
const runner = createRunner([
pullRequestJson("First result"),
pullRequestJson("Second result"),
]);
const service = createGitHubService({
ttlMs: 50,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => now,
});
const first = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
now = 151;
const second = await service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
expect(first[0]?.title).toBe("First result");
expect(second[0]?.title).toBe("Second result");
expect(runner.calls).toHaveLength(2);
});
it("coalesces concurrent identical calls into one runner invocation", async () => {
const runner = createDeferredRunner();
const service = createGitHubService({
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const first = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
const second = service.listPullRequests({ cwd: "/repo", query: "bug", limit: 10 });
await Promise.resolve();
runner.resolveNext(pullRequestJson("Shared result"));
await expect(Promise.all([first, second])).resolves.toEqual([
[
{
number: 123,
title: "Shared result",
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: "feature",
labels: ["bug"],
},
],
[
{
number: 123,
title: "Shared result",
url: "https://github.com/acme/repo/pull/123",
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: "feature",
labels: ["bug"],
},
],
]);
expect(runner.calls).toHaveLength(1);
});
it("invalidates only cache entries matching the requested cwd", async () => {
const runner = createRunner([
pullRequestJson("Repo one"),
pullRequestJson("Repo two"),
pullRequestJson("Repo one refreshed"),
]);
const service = createGitHubService({
ttlMs: 1_000,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await service.listPullRequests({ cwd: "/repo-one", query: "bug", limit: 10 });
await service.listPullRequests({ cwd: "/repo-two", query: "bug", limit: 10 });
service.invalidate({ cwd: "/repo-one" });
const refreshed = await service.listPullRequests({ cwd: "/repo-one", query: "bug", limit: 10 });
const cached = await service.listPullRequests({ cwd: "/repo-two", query: "bug", limit: 10 });
expect(refreshed[0]?.title).toBe("Repo one refreshed");
expect(cached[0]?.title).toBe("Repo two");
expect(runner.calls).toHaveLength(3);
});
it("throws a typed missing-cli error when gh is unavailable", async () => {
const runner = createRunner([]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => null,
now: () => 100,
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toBeInstanceOf(
GitHubCliMissingError,
);
expect(runner.calls).toHaveLength(0);
});
it("throws a typed auth error for authentication failures", async () => {
const service = createGitHubService({
runner: async () => {
throw new GitHubCommandError({
args: ["auth", "status"],
cwd: "/repo",
exitCode: 1,
stderr: "To authenticate, run: gh auth login",
});
},
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(service.isAuthenticated({ cwd: "/repo" })).rejects.toBeInstanceOf(
GitHubAuthenticationError,
);
});
it("throws a typed command error for non-zero exits", async () => {
const service = createGitHubService({
runner: async () => {
throw new GitHubCommandError({
args: ["pr", "list"],
cwd: "/repo",
exitCode: 2,
stderr: "GraphQL: unavailable",
});
},
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toMatchObject({
kind: "command-error",
exitCode: 2,
stderr: "GraphQL: unavailable",
});
});
});
@@ -0,0 +1,835 @@
import { z } from "zod";
import { findExecutable } from "../utils/executable.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { execCommand } from "../utils/spawn.js";
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
const GITHUB_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
};
const LabelSchema = z.object({
name: z.string().optional(),
});
const GitHubIssueSummarySchema = z.object({
number: z.number(),
title: z.string().catch(""),
url: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
labels: z.array(LabelSchema).catch([]),
});
const GitHubPullRequestSummarySchema = z.object({
number: z.number(),
title: z.string().catch(""),
url: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
labels: z.array(LabelSchema).catch([]),
});
const PullRequestCheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
name: z.string(),
conclusion: z.string().nullable().optional(),
status: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
startedAt: z.string().nullable().optional(),
completedAt: z.string().nullable().optional(),
checkSuite: z
.object({
workflowRun: z
.object({
databaseId: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
});
const PullRequestStatusContextNodeSchema = z.object({
__typename: z.literal("StatusContext"),
context: z.string(),
state: z.string().nullable().optional(),
targetUrl: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(),
});
const PullRequestStatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [
PullRequestCheckRunNodeSchema,
PullRequestStatusContextNodeSchema,
]);
const PullRequestStatusCheckRollupArraySchema = z.array(z.unknown());
const LegacyPullRequestStatusCheckRollupSchema = z.object({
contexts: z.array(z.unknown()),
});
const PullRequestReviewDecisionSchema = z
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
.nullable()
.catch(null);
const CurrentPullRequestStatusSchema = z.object({
url: z.string().catch(""),
title: z.string().catch(""),
state: z.string().catch(""),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
});
interface CacheEntry {
value: unknown;
expiresAt: number;
cwd: string;
}
interface GitHubServiceDependencies {
runner: GitHubCommandRunner;
resolveGhPath: () => Promise<string | null>;
now: () => number;
}
export interface GitHubCommandRunnerOptions {
cwd: string;
}
export interface GitHubCommandResult {
stdout: string;
stderr: string;
}
export type GitHubCommandRunner = (
args: string[],
options: GitHubCommandRunnerOptions,
) => Promise<GitHubCommandResult>;
export interface GitHubPullRequestSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
baseRefName: string;
headRefName: string;
labels: string[];
}
export interface GitHubIssueSummary {
number: number;
title: string;
url: string;
state: string;
body: string | null;
labels: string[];
}
export type PullRequestCheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped";
export interface PullRequestCheck {
name: string;
status: PullRequestCheckStatus;
url: string | null;
}
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export interface GitHubCurrentPullRequestStatus {
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
}
export interface GitHubPullRequestCreateResult {
url: string;
number: number;
}
export interface ListGitHubPullRequestsOptions {
cwd: string;
query?: string;
limit?: number;
}
export interface ListGitHubIssuesOptions {
cwd: string;
query?: string;
limit?: number;
}
export interface GetGitHubPullRequestOptions {
cwd: string;
number: number;
}
export interface CreateGitHubPullRequestOptions {
cwd: string;
repo: string;
title: string;
head: string;
base: string;
body?: string;
}
export interface GitHubService {
listPullRequests(options: ListGitHubPullRequestsOptions): Promise<GitHubPullRequestSummary[]>;
listIssues(options: ListGitHubIssuesOptions): Promise<GitHubIssueSummary[]>;
getPullRequest(options: GetGitHubPullRequestOptions): Promise<GitHubPullRequestSummary>;
getPullRequestHeadRef(options: GetGitHubPullRequestOptions): Promise<string>;
getCurrentPullRequestStatus(options: {
cwd: string;
headRef: string;
}): Promise<GitHubCurrentPullRequestStatus | null>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
isAuthenticated(options: { cwd: string }): Promise<boolean>;
invalidate(options: { cwd: string }): void;
}
export class GitHubCliMissingError extends Error {
readonly kind = "missing-cli";
constructor() {
super("GitHub CLI (gh) is not installed or not in PATH");
this.name = "GitHubCliMissingError";
}
}
export class GitHubAuthenticationError extends Error {
readonly kind = "auth-failure";
readonly stderr: string;
constructor(params: { stderr: string }) {
super("GitHub CLI authentication failed");
this.name = "GitHubAuthenticationError";
this.stderr = params.stderr;
}
}
export class GitHubCommandError extends Error {
readonly kind = "command-error";
readonly args: string[];
readonly cwd: string;
readonly exitCode: number | null;
readonly stderr: string;
constructor(params: { args: string[]; cwd: string; exitCode: number | null; stderr: string }) {
super(`GitHub CLI command failed: gh ${params.args.join(" ")}`);
this.name = "GitHubCommandError";
this.args = [...params.args];
this.cwd = params.cwd;
this.exitCode = params.exitCode;
this.stderr = params.stderr;
}
}
interface CreateGitHubServiceOptions {
ttlMs?: number;
runner?: GitHubCommandRunner;
resolveGhPath?: () => Promise<string | null>;
now?: () => number;
}
interface CommandFailureLike {
code?: string | number | null;
stderr?: string | Buffer;
stdout?: string | Buffer;
message?: string;
}
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
const ttlMs = options.ttlMs ?? DEFAULT_GITHUB_CACHE_TTL_MS;
const deps: GitHubServiceDependencies = {
runner: options.runner ?? runGhCommand,
resolveGhPath: options.resolveGhPath ?? resolveGhPath,
now: options.now ?? Date.now,
};
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<unknown>>();
async function cached<T>(params: {
cwd: string;
method: string;
args: unknown;
load: () => Promise<T>;
}): Promise<T> {
const key = buildCacheKey({
cwd: params.cwd,
method: params.method,
args: params.args,
});
const cachedEntry = cache.get(key);
const now = deps.now();
if (cachedEntry && cachedEntry.expiresAt > now) {
return cachedEntry.value as T;
}
const existing = inFlight.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = params
.load()
.then((value) => {
cache.set(key, {
value,
cwd: params.cwd,
expiresAt: deps.now() + ttlMs,
});
return value;
})
.finally(() => {
inFlight.delete(key);
});
inFlight.set(key, request);
return request;
}
async function run(args: string[], options: GitHubCommandRunnerOptions): Promise<string> {
const ghPath = await deps.resolveGhPath();
if (!ghPath) {
throw new GitHubCliMissingError();
}
try {
const result = await deps.runner(args, options);
return result.stdout.trim();
} catch (error) {
throw normalizeGitHubCommandError(error, {
args,
cwd: options.cwd,
});
}
}
return {
listPullRequests(options) {
return cached({
cwd: options.cwd,
method: "listPullRequests",
args: { query: options.query ?? "", limit: options.limit ?? 20 },
load: async () => {
const stdout = await run(
[
"pr",
"list",
"--search",
options.query ?? "",
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName",
"--limit",
String(options.limit ?? 20),
],
{ cwd: options.cwd },
);
return parsePullRequestSummaries(stdout);
},
});
},
listIssues(options) {
return cached({
cwd: options.cwd,
method: "listIssues",
args: { query: options.query ?? "", limit: options.limit ?? 20 },
load: async () => {
const stdout = await run(
[
"issue",
"list",
"--search",
options.query ?? "",
"--json",
"number,title,url,state,body,labels",
"--limit",
String(options.limit ?? 20),
],
{ cwd: options.cwd },
);
return parseIssueSummaries(stdout);
},
});
},
getPullRequest(options) {
return cached({
cwd: options.cwd,
method: "getPullRequest",
args: { number: options.number },
load: async () => {
const stdout = await run(
[
"pr",
"view",
String(options.number),
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName",
],
{ cwd: options.cwd },
);
return parsePullRequestSummary(stdout);
},
});
},
async getPullRequestHeadRef(options) {
const pullRequest = await this.getPullRequest(options);
return pullRequest.headRefName;
},
getCurrentPullRequestStatus(options) {
return cached({
cwd: options.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: options.headRef },
load: async () => {
try {
const stdout = await run(
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision",
],
{ cwd: options.cwd },
);
return parseCurrentPullRequestStatus(stdout, options.headRef);
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return null;
}
throw error;
}
},
});
},
async createPullRequest(options) {
const args = [
"api",
"-X",
"POST",
`repos/${options.repo}/pulls`,
"-f",
`title=${options.title}`,
];
args.push("-f", `head=${options.head}`);
args.push("-f", `base=${options.base}`);
if (options.body) {
args.push("-f", `body=${options.body}`);
}
const stdout = await run(args, { cwd: options.cwd });
const parsed = z
.object({
url: z.string(),
number: z.number(),
})
.parse(JSON.parse(stdout || "{}"));
return parsed;
},
isAuthenticated(options) {
return cached({
cwd: options.cwd,
method: "isAuthenticated",
args: {},
load: async () => {
try {
await run(["auth", "status"], { cwd: options.cwd });
return true;
} catch (error) {
if (isGitHubAuthenticationError(error)) {
throw error;
}
if (error instanceof GitHubCommandError && isAuthFailureText(error.stderr)) {
throw new GitHubAuthenticationError({ stderr: error.stderr });
}
throw error;
}
},
});
},
invalidate(options) {
for (const [key, entry] of cache.entries()) {
if (entry.cwd === options.cwd) {
cache.delete(key);
}
}
},
};
}
async function resolveGhPath(): Promise<string | null> {
return findExecutable("gh");
}
async function runGhCommand(
args: string[],
options: GitHubCommandRunnerOptions,
): Promise<GitHubCommandResult> {
return execCommand("gh", args, {
cwd: options.cwd,
env: GITHUB_ENV,
maxBuffer: 10 * 1024 * 1024,
});
}
function buildCacheKey(params: { cwd: string; method: string; args: unknown }): string {
return `${params.cwd}:${params.method}:${stableStringify(params.args)}`;
}
function stableStringify(value: unknown): string {
return JSON.stringify(sortJsonValue(value));
}
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (!value || typeof value !== "object") {
return value;
}
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
const sorted: Record<string, unknown> = {};
for (const [key, entryValue] of entries) {
sorted[key] = sortJsonValue(entryValue);
}
return sorted;
}
function normalizeGitHubCommandError(
error: unknown,
context: { args: string[]; cwd: string },
): Error {
if (error instanceof GitHubAuthenticationError) {
return error;
}
if (error instanceof GitHubCommandError) {
if (isAuthFailureText(error.stderr)) {
return new GitHubAuthenticationError({ stderr: error.stderr });
}
return error;
}
const failure = toCommandFailureLike(error);
if (failure.code === "ENOENT") {
return new GitHubCliMissingError();
}
const stderr = bufferOrStringToString(failure.stderr);
if (isAuthFailureText(stderr) || isAuthFailureText(failure.message ?? "")) {
return new GitHubAuthenticationError({ stderr });
}
return new GitHubCommandError({
args: context.args,
cwd: context.cwd,
exitCode: typeof failure.code === "number" ? failure.code : null,
stderr,
});
}
function toCommandFailureLike(error: unknown): CommandFailureLike {
if (!error || typeof error !== "object") {
return { message: String(error) };
}
const record = error as Record<string, unknown>;
return {
code:
typeof record.code === "string" || typeof record.code === "number" || record.code === null
? record.code
: undefined,
stderr:
typeof record.stderr === "string" || Buffer.isBuffer(record.stderr)
? record.stderr
: undefined,
stdout:
typeof record.stdout === "string" || Buffer.isBuffer(record.stdout)
? record.stdout
: undefined,
message: typeof record.message === "string" ? record.message : undefined,
};
}
function bufferOrStringToString(value: string | Buffer | undefined): string {
if (Buffer.isBuffer(value)) {
return value.toString("utf8");
}
return value ?? "";
}
function isGitHubAuthenticationError(error: unknown): error is GitHubAuthenticationError {
return error instanceof GitHubAuthenticationError;
}
function isAuthFailureText(text: string): boolean {
const normalized = text.toLowerCase();
return (
normalized.includes("gh auth login") ||
normalized.includes("not logged into any github hosts") ||
normalized.includes("authentication failed") ||
normalized.includes("authentication required") ||
normalized.includes("bad credentials") ||
normalized.includes("http 401")
);
}
function isNoPullRequestFoundError(error: unknown): boolean {
if (!(error instanceof GitHubCommandError)) {
return false;
}
const text = error.stderr.toLowerCase();
return text.includes("no pull requests found") || text.includes("could not resolve");
}
function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] {
const parsed = z.array(GitHubPullRequestSummarySchema).parse(JSON.parse(stdout || "[]"));
return parsed.map(toPullRequestSummary);
}
function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary {
return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}")));
}
function toPullRequestSummary(
item: z.infer<typeof GitHubPullRequestSummarySchema>,
): GitHubPullRequestSummary {
return {
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
};
}
function parseIssueSummaries(stdout: string): GitHubIssueSummary[] {
const parsed = z.array(GitHubIssueSummarySchema).parse(JSON.parse(stdout || "[]"));
return parsed.map((item) => ({
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
}));
}
function parseCurrentPullRequestStatus(
stdout: string,
fallbackHeadRefName: string,
): GitHubCurrentPullRequestStatus | null {
const item = CurrentPullRequestStatusSchema.parse(JSON.parse(stdout || "{}"));
if (!item.url || !item.title) {
return null;
}
const mergedAt =
typeof item.mergedAt === "string" && item.mergedAt.trim().length > 0 ? item.mergedAt : null;
const state =
mergedAt !== null ? "merged" : item.state.trim().length > 0 ? item.state.toLowerCase() : "";
const checks = parseStatusCheckRollup(item.statusCheckRollup);
return {
url: item.url,
title: item.title,
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision: mapReviewDecision(item.reviewDecision),
};
}
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
const directContexts = PullRequestStatusCheckRollupArraySchema.safeParse(value);
if (!directContexts.success) {
const legacyContexts = LegacyPullRequestStatusCheckRollupSchema.safeParse(value);
if (!legacyContexts.success) {
return [];
}
return parseStatusCheckRollup(legacyContexts.data.contexts);
}
const dedupedChecks = new Map<string, PullRequestCheck & { recency: number }>();
for (const entry of directContexts.data) {
const parsed = PullRequestStatusCheckRollupNodeSchema.safeParse(entry);
if (!parsed.success) {
continue;
}
const check = buildPullRequestCheck(parsed.data);
if (!check) {
continue;
}
const existing = dedupedChecks.get(check.name);
if (!existing || check.recency > existing.recency) {
dedupedChecks.set(check.name, check);
}
}
return Array.from(dedupedChecks.values(), ({ recency: _recency, ...check }) => check);
}
function buildPullRequestCheck(
context: z.infer<typeof PullRequestStatusCheckRollupNodeSchema>,
): (PullRequestCheck & { recency: number }) | null {
if (context.__typename === "CheckRun") {
return {
name: context.name,
status: mapCheckRunStatus(context.status, context.conclusion),
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
recency: getCheckRunRecency(context),
};
}
if (context.__typename === "StatusContext") {
return {
name: context.context,
status: mapStatusContextState(context.state),
url: typeof context.targetUrl === "string" ? context.targetUrl : null,
recency: getStatusContextRecency(context),
};
}
return null;
}
function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheckStatus {
if (status !== "COMPLETED") {
return "pending";
}
switch (conclusion) {
case "SUCCESS":
return "success";
case "FAILURE":
case "TIMED_OUT":
case "ACTION_REQUIRED":
return "failure";
case "CANCELLED":
return "cancelled";
case "SKIPPED":
case "NEUTRAL":
return "skipped";
default:
return "pending";
}
}
function mapStatusContextState(state: unknown): PullRequestCheckStatus {
switch (state) {
case "SUCCESS":
return "success";
case "FAILURE":
case "ERROR":
return "failure";
case "EXPECTED":
case "PENDING":
return "pending";
default:
return "pending";
}
}
function getCheckRunRecency(context: PullRequestCheckRunNode): number {
const workflowRunId = context.checkSuite?.workflowRun?.databaseId;
if (typeof workflowRunId === "number") {
return workflowRunId;
}
return parseOptionalTime(context.completedAt ?? context.startedAt ?? null);
}
function getStatusContextRecency(context: PullRequestStatusContextNode): number {
return parseOptionalTime(context.createdAt ?? null);
}
function parseOptionalTime(timestamp: string | null): number {
if (!timestamp) {
return 0;
}
const time = Date.parse(timestamp);
return Number.isNaN(time) ? 0 : time;
}
function computeChecksStatus(checks: PullRequestCheck[]): PullRequestChecksStatus {
if (checks.length === 0) {
return "none";
}
if (checks.some((check) => check.status === "failure")) {
return "failure";
}
if (checks.some((check) => check.status === "pending")) {
return "pending";
}
return "success";
}
function mapReviewDecision(value: unknown): PullRequestReviewDecision {
const reviewDecision = PullRequestReviewDecisionSchema.parse(value);
if (reviewDecision === "APPROVED") {
return "approved";
}
if (reviewDecision === "CHANGES_REQUESTED") {
return "changes_requested";
}
if (reviewDecision === "REVIEW_REQUIRED") {
return "pending";
}
return null;
}
export async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
});
return parseGitHubRepoFromRemote(stdout.trim());
} catch {
return null;
}
}
function parseGitHubRepoFromRemote(url: string): string | null {
if (!url) {
return null;
}
let cleaned = url;
if (cleaned.startsWith("git@github.com:")) {
cleaned = cleaned.slice("git@github.com:".length);
} else if (cleaned.startsWith("https://github.com/")) {
cleaned = cleaned.slice("https://github.com/".length);
} else if (cleaned.startsWith("http://github.com/")) {
cleaned = cleaned.slice("http://github.com/".length);
} else {
const marker = "github.com/";
const index = cleaned.indexOf(marker);
if (index === -1) {
return null;
}
cleaned = cleaned.slice(index + marker.length);
}
if (cleaned.endsWith(".git")) {
cleaned = cleaned.slice(0, -".git".length);
}
return cleaned.includes("/") ? cleaned : null;
}
@@ -115,4 +115,71 @@ describe("shared messages attachments", () => {
},
]);
});
it("parses old-shape create-worktree payloads without the new intent fields", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
requestId: "req-4",
cwd: "/tmp/repo",
attachments: [],
});
expect(parsed).toEqual({
type: "create_paseo_worktree_request",
requestId: "req-4",
cwd: "/tmp/repo",
attachments: [],
});
});
it("accepts and strips create-worktree intent fields compatibly", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
requestId: "req-5",
cwd: "/tmp/repo",
attachments: [],
action: "checkout",
refName: "feature/ref-picker",
githubPrNumber: 42,
futureField: "ignored",
});
expect(parsed).toEqual({
type: "create_paseo_worktree_request",
requestId: "req-5",
cwd: "/tmp/repo",
attachments: [],
action: "checkout",
refName: "feature/ref-picker",
githubPrNumber: 42,
});
});
it("accepts optional create-agent git intent fields and strips unknown git fields", () => {
const parsed = CreateAgentRequestMessageSchema.parse({
type: "create_agent_request",
requestId: "req-6",
config: {
provider: "codex",
cwd: "/tmp/repo",
},
attachments: [],
git: {
createWorktree: true,
worktreeSlug: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
futureGitField: "ignored",
},
});
expect(parsed.git).toEqual({
createWorktree: true,
worktreeSlug: "review-42",
action: "checkout",
refName: "head-ref",
githubPrNumber: 42,
});
});
});
+12
View File
@@ -837,6 +837,9 @@ const GitSetupOptionsSchema = z.object({
newBranchName: z.string().optional(),
createWorktree: z.boolean().optional(),
worktreeSlug: z.string().optional(),
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
githubPrNumber: z.number().int().positive().optional(),
});
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
@@ -1188,11 +1191,14 @@ export const GitHubSearchItemSchema = z.object({
headRefName: z.string().nullable().optional(),
});
export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]);
export const GitHubSearchRequestSchema = z.object({
type: z.literal("github_search_request"),
cwd: z.string(),
query: z.string(),
limit: z.number().int().min(1).max(50).optional(),
kinds: z.array(GitHubSearchKindSchema).optional(),
requestId: z.string(),
});
@@ -1226,6 +1232,9 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
cwd: z.string(),
worktreeSlug: z.string().optional(),
attachments: AgentAttachmentsSchema,
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
githubPrNumber: z.number().int().positive().optional(),
requestId: z.string(),
});
@@ -1821,6 +1830,7 @@ export const AgentCreateFailedStatusPayloadSchema = z.object({
status: z.literal("agent_create_failed"),
requestId: z.string(),
error: z.string(),
errorCode: z.string().optional(),
});
export const AgentResumedStatusPayloadSchema = z
@@ -2652,6 +2662,7 @@ export const CreatePaseoWorktreeResponseSchema = z.object({
payload: z.object({
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
errorCode: z.string().optional(),
setupTerminalId: z.string().nullable(),
requestId: z.string(),
}),
@@ -3228,6 +3239,7 @@ export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
export type GitHubSearchItem = z.infer<typeof GitHubSearchItemSchema>;
export type GitHubSearchKind = z.infer<typeof GitHubSearchKindSchema>;
export type GitHubSearchRequest = z.infer<typeof GitHubSearchRequestSchema>;
export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
export type CreatePaseoWorktreeRequest = z.infer<typeof CreatePaseoWorktreeRequestSchema>;
+286 -236
View File
@@ -1,22 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import {
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
realpathSync,
mkdirSync,
symlinkSync,
} from "fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, realpathSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
__resetGhPathCacheForTests,
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
__setGhPathForTests,
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
@@ -38,9 +27,48 @@ import {
parseStatusCheckRollup,
isPaseoWorktreePath,
isDescendantPath,
searchGitHubIssuesAndPrs,
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { createWorktree } from "./worktree.js";
import {
GitHubCliMissingError,
type GitHubCurrentPullRequestStatus,
type GitHubService,
} from "../services/github-service.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree.js";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
function initRepo(): { tempDir: string; repoDir: string } {
@@ -60,6 +88,52 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createGitHubServiceForStatus(
status: GitHubCurrentPullRequestStatus | null,
options?: { onStatus?: () => void },
): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async () => ({
number: 1,
title: "PR",
url: "https://github.com/getpaseo/paseo/pull/1",
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: "feature",
labels: [],
}),
getPullRequestHeadRef: async () => "feature",
getCurrentPullRequestStatus: async () => {
options?.onStatus?.();
return status;
},
createPullRequest: async () => ({
url: "https://github.com/getpaseo/paseo/pull/1",
number: 1,
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestStatus>) {
return {
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship feature",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
checks: [],
checksStatus: "none" as const,
reviewDecision: null,
...overrides,
};
}
describe("checkout git utilities", () => {
let tempDir: string;
let repoDir: string;
@@ -70,13 +144,11 @@ describe("checkout git utilities", () => {
tempDir = setup.tempDir;
repoDir = setup.repoDir;
paseoHome = join(tempDir, "paseo-home");
__resetGhPathCacheForTests();
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
});
afterEach(() => {
__resetGhPathCacheForTests();
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
rmSync(tempDir, { recursive: true, force: true });
@@ -383,7 +455,7 @@ const x = 1;
});
it("handles status/diff/commit in a .paseo worktree", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -417,7 +489,7 @@ const x = 1;
});
it("returns checkout root metadata for .paseo worktrees", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -444,7 +516,7 @@ const x = 1;
execSync("git config user.email 'test@test.com'", { cwd: mainCheckoutDir });
execSync("git config user.name 'Test'", { cwd: mainCheckoutDir });
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: mainCheckoutDir,
baseBranch: "main",
@@ -459,7 +531,7 @@ const x = 1;
});
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -720,20 +792,130 @@ const x = 1;
it("disables GitHub features when gh is unavailable", async () => {
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async () => {
throw new GitHubCliMissingError();
};
const status = await getPullRequestStatus(repoDir, github);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
});
try {
__setGhPathForTests(null);
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
it("searches GitHub issues and PRs through the GitHub service", async () => {
let issueCalls = 0;
let pullRequestCalls = 0;
const github = createGitHubServiceForStatus(null);
github.listIssues = async (options) => {
issueCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 55,
title: "Issue title",
url: "https://github.com/getpaseo/paseo/issues/55",
state: "OPEN",
body: "issue body",
labels: ["bug"],
},
];
};
github.listPullRequests = async (options) => {
pullRequestCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
},
];
};
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github);
expect(issueCalls).toBe(1);
expect(pullRequestCalls).toBe(1);
expect(result).toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "issue",
number: 55,
title: "Issue title",
url: "https://github.com/getpaseo/paseo/issues/55",
state: "OPEN",
body: "issue body",
labels: ["bug"],
baseRefName: null,
headRefName: null,
},
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
},
],
});
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
let issueCalls = 0;
let pullRequestCalls = 0;
const github = createGitHubServiceForStatus(null);
github.listIssues = async () => {
issueCalls += 1;
return [];
};
github.listPullRequests = async (options) => {
pullRequestCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
},
];
};
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github, {
kinds: ["github-pr"],
});
expect(issueCalls).toBe(0);
expect(pullRequestCalls).toBe(1);
expect(result).toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
},
],
});
});
it("parses real gh status check rollup output and dedupes by latest check run", () => {
@@ -835,189 +1017,89 @@ const x = 1;
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-merged");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z","statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
const status = await getPullRequestStatus(
repoDir,
createGitHubServiceForStatus(
createPullRequestStatus({
state: "merged",
isMerged: true,
}),
),
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/123");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/123");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
});
it("returns closed-unmerged PR status without marking it as merged", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-closed");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
const status = await getPullRequestStatus(
repoDir,
createGitHubServiceForStatus(
createPullRequestStatus({
url: "https://github.com/getpaseo/paseo/pull/999",
title: "Closed without merge",
state: "closed",
}),
),
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/999");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(false);
expect(status.status?.state).toBe("closed");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/999");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(false);
expect(status.status?.state).toBe("closed");
});
it("caches PR status results for duplicate lookups", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-hit");
const callCountPath = join(tempDir, "gh-call-count.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
const second = await getPullRequestStatus(repoDir);
expect(first).toEqual(second);
expect(first.status?.url).toContain("/pull/123");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
let callCount = 0;
const github = createGitHubServiceForStatus(createPullRequestStatus(), {
onStatus: () => {
callCount += 1;
},
});
const first = await getPullRequestStatus(repoDir, github);
const second = await getPullRequestStatus(repoDir, github);
expect(first).toEqual(second);
expect(first.status?.url).toContain("/pull/123");
expect(callCount).toBe(1);
});
it("expires cached PR status after the TTL", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-expiry");
const callCountPath = join(tempDir, "gh-call-count-expiry.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' next="$((count + 1))"',
' printf "%s\\n" "$next" > "$count_file"',
' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\\n\' "$next"',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
__setPullRequestStatusCacheTtlForTests(50);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
let callCount = 0;
const github = createGitHubServiceForStatus(null, {
onStatus: () => {
callCount += 1;
},
});
github.getCurrentPullRequestStatus = async () => {
callCount += 1;
return createPullRequestStatus({
url: `https://github.com/getpaseo/paseo/pull/${callCount}`,
});
};
const first = await getPullRequestStatus(repoDir, github);
await sleep(80);
const second = await getPullRequestStatus(repoDir);
const second = await getPullRequestStatus(repoDir, github);
expect(first.status?.url).toContain("/pull/1");
expect(second.status?.url).toContain("/pull/2");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("2");
expect(callCount).toBe(2);
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -1026,50 +1108,18 @@ const x = 1;
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-concurrent");
const callCountPath = join(tempDir, "gh-call-count-concurrent.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
" sleep 0.2",
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const [first, second] = await Promise.all([
getPullRequestStatus(repoDir),
getPullRequestStatus(repoDir),
]);
expect(first).toEqual(second);
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
let callCount = 0;
const github = createGitHubServiceForStatus(createPullRequestStatus(), {
onStatus: () => {
callCount += 1;
},
});
const [first, second] = await Promise.all([
getPullRequestStatus(repoDir, github),
getPullRequestStatus(repoDir, github),
]);
expect(first).toEqual(second);
expect(callCount).toBe(1);
});
it("returns typed MergeConflictError on merge conflicts", async () => {
@@ -1110,7 +1160,7 @@ const x = 1;
execSync("git checkout main", { cwd: repoDir });
// Create a worktree/branch based on develop, but keep main as the repo default.
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: repoDir,
baseBranch: "develop",
@@ -1156,7 +1206,7 @@ const x = 1;
execSync("git checkout main", { cwd: repoDir });
// Create a Paseo worktree configured to use develop as base.
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: repoDir,
baseBranch: "develop",
@@ -1189,7 +1239,7 @@ const x = 1;
});
it("throws if Paseo worktree base metadata is missing", async () => {
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
+92 -418
View File
@@ -2,13 +2,19 @@ import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import { z } from "zod";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import type { GitHubSearchKind } from "../shared/messages.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { findExecutable } from "./executable.js";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
createGitHubService,
resolveGitHubRepo,
type GitHubService,
} from "../services/github-service.js";
export { parseStatusCheckRollup } from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { execCommand } from "./spawn.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
@@ -24,7 +30,6 @@ const SHORTSTAT_CACHE_MAX = 1_000;
let pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
let cachedGhPath: string | null | undefined = undefined;
let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
@@ -69,14 +74,6 @@ export function __setPullRequestStatusCacheTtlForTests(ttlMs: number): void {
pullRequestStatusInFlight.clear();
}
export function __resetGhPathCacheForTests(): void {
cachedGhPath = undefined;
}
export function __setGhPathForTests(path: string | null): void {
cachedGhPath = path;
}
export function __resetCheckoutShortstatCacheForTests(): void {
shortstatCache.clear();
shortstatCache.cancelTimer();
@@ -1837,279 +1834,12 @@ export type ChecksStatus = "none" | "pending" | "success" | "failure";
export type ReviewDecision = "approved" | "changes_requested" | "pending" | null;
const CheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
name: z.string(),
conclusion: z.string().nullable().optional(),
status: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
startedAt: z.string().nullable().optional(),
completedAt: z.string().nullable().optional(),
checkSuite: z
.object({
workflowRun: z
.object({
databaseId: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
});
const StatusContextNodeSchema = z.object({
__typename: z.literal("StatusContext"),
context: z.string(),
state: z.string().nullable().optional(),
targetUrl: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(),
});
const StatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [
CheckRunNodeSchema,
StatusContextNodeSchema,
]);
const StatusCheckRollupArraySchema = z.array(z.unknown());
const LegacyStatusCheckRollupSchema = z.object({
contexts: z.array(z.unknown()),
});
const ReviewDecisionSchema = z
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
.nullable()
.catch(null);
type CheckRunNode = z.infer<typeof CheckRunNodeSchema>;
type StatusContextNode = z.infer<typeof StatusContextNodeSchema>;
export async function resolveGhPath(): Promise<string> {
if (cachedGhPath === undefined) {
cachedGhPath = await findExecutable("gh");
}
if (cachedGhPath === null) {
throw new Error("GitHub CLI (gh) is not installed or not in PATH");
}
return cachedGhPath;
}
function getCommandErrorText(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
}
function isGhAuthError(error: unknown): boolean {
const text = getCommandErrorText(error);
return (
text.includes("gh auth login") ||
text.includes("not logged into any github hosts") ||
text.includes("authentication failed") ||
text.includes("authentication required") ||
text.includes("bad credentials") ||
text.includes("http 401")
);
}
function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheck["status"] {
if (status !== "COMPLETED") {
return "pending";
}
switch (conclusion) {
case "SUCCESS":
return "success";
case "FAILURE":
case "TIMED_OUT":
case "ACTION_REQUIRED":
return "failure";
case "CANCELLED":
return "cancelled";
case "SKIPPED":
case "NEUTRAL":
return "skipped";
default:
return "pending";
}
}
function mapStatusContextState(state: unknown): PullRequestCheck["status"] {
switch (state) {
case "SUCCESS":
return "success";
case "FAILURE":
case "ERROR":
return "failure";
case "EXPECTED":
case "PENDING":
return "pending";
default:
return "pending";
}
}
function getCheckRunRecency(context: CheckRunNode): number {
const workflowRunId = context.checkSuite?.workflowRun?.databaseId;
if (typeof workflowRunId === "number") {
return workflowRunId;
}
const timestamp =
typeof context.completedAt === "string"
? context.completedAt
: typeof context.startedAt === "string"
? context.startedAt
: null;
if (!timestamp) {
return 0;
}
const time = Date.parse(timestamp);
return Number.isNaN(time) ? 0 : time;
}
function getStatusContextRecency(context: StatusContextNode): number {
if (typeof context.createdAt !== "string" || context.createdAt.length === 0) {
return 0;
}
const time = Date.parse(context.createdAt);
return Number.isNaN(time) ? 0 : time;
}
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
const directContexts = StatusCheckRollupArraySchema.safeParse(value);
if (!directContexts.success) {
const legacyContexts = LegacyStatusCheckRollupSchema.safeParse(value);
if (!legacyContexts.success) {
return [];
}
return parseStatusCheckRollup(legacyContexts.data.contexts);
}
const contexts = directContexts.data;
const dedupedChecks = new Map<
string,
PullRequestCheck & {
recency: number;
}
>();
for (const entry of contexts) {
const parsed = StatusCheckRollupNodeSchema.safeParse(entry);
if (!parsed.success) {
continue;
}
const context = parsed.data;
let check: (PullRequestCheck & { recency: number }) | null = null;
if (context.__typename === "CheckRun") {
check = {
name: context.name,
status: mapCheckRunStatus(context.status, context.conclusion),
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
recency: getCheckRunRecency(context),
};
} else if (context.__typename === "StatusContext") {
check = {
name: context.context,
status: mapStatusContextState(context.state),
url: typeof context.targetUrl === "string" ? context.targetUrl : null,
recency: getStatusContextRecency(context),
};
}
if (!check) {
continue;
}
const existing = dedupedChecks.get(check.name);
if (!existing || check.recency > existing.recency) {
dedupedChecks.set(check.name, check);
}
}
return Array.from(dedupedChecks.values(), ({ recency: _recency, ...check }) => check);
}
function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus {
if (checks.length === 0) {
return "none";
}
if (checks.some((check) => check.status === "failure")) {
return "failure";
}
if (checks.some((check) => check.status === "pending")) {
return "pending";
}
return "success";
}
function mapReviewDecision(value: unknown): ReviewDecision {
const reviewDecision = ReviewDecisionSchema.parse(value);
if (reviewDecision === "APPROVED") {
return "approved";
}
if (reviewDecision === "CHANGES_REQUESTED") {
return "changes_requested";
}
if (reviewDecision === "REVIEW_REQUIRED") {
return "pending";
}
return null;
}
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const url = stdout.trim();
if (!url) {
return null;
}
let cleaned = url;
if (cleaned.startsWith("git@github.com:")) {
cleaned = cleaned.slice("git@github.com:".length);
} else if (cleaned.startsWith("https://github.com/")) {
cleaned = cleaned.slice("https://github.com/".length);
} else if (cleaned.startsWith("http://github.com/")) {
cleaned = cleaned.slice("http://github.com/".length);
} else {
const marker = "github.com/";
const index = cleaned.indexOf(marker);
if (index !== -1) {
cleaned = cleaned.slice(index + marker.length);
} else {
return null;
}
}
if (cleaned.endsWith(".git")) {
cleaned = cleaned.slice(0, -".git".length);
}
if (!cleaned.includes("/")) {
return null;
}
return cleaned;
} catch {
// ignore
}
return null;
}
export async function createPullRequest(
cwd: string,
options: CreatePullRequestOptions,
github: GitHubService = createGitHubService(),
): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd);
const ghPath = await resolveGhPath();
const repo = await resolveGitHubRepo(cwd);
if (!repo) {
throw new Error("Unable to determine GitHub repo from git remote");
@@ -2131,22 +1861,20 @@ export async function createPullRequest(
await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 });
const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
args.push("-f", `head=${head}`);
args.push("-f", `base=${normalizedBase}`);
if (options.body) {
args.push("-f", `body=${options.body}`);
}
const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv });
const parsed = JSON.parse(stdout.trim());
if (!parsed?.url || !parsed?.number) {
throw new Error("GitHub CLI did not return PR url/number");
}
return { url: parsed.url, number: parsed.number };
return github.createPullRequest({
cwd,
repo,
title: options.title,
body: options.body,
head,
base: normalizedBase,
});
}
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
export async function getPullRequestStatus(
cwd: string,
github: GitHubService = createGitHubService(),
): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd);
const cached = pullRequestStatusCache.get(cacheKey);
if (cached) {
@@ -2158,7 +1886,7 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
return existing;
}
const lookup = getPullRequestStatusUncached(cwd)
const lookup = getPullRequestStatusUncached(cwd, github)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
return status;
@@ -2171,7 +1899,10 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
return lookup;
}
async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestStatusResult> {
async function getPullRequestStatusUncached(
cwd: string,
github: GitHubService,
): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
const head = await getCurrentBranch(cwd);
if (!head) {
@@ -2180,63 +1911,16 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
githubFeaturesEnabled: false,
};
}
let ghPath: string;
try {
ghPath = await resolveGhPath();
} catch {
const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head });
return {
status: null,
githubFeaturesEnabled: false,
};
}
try {
const { stdout } = await execCommand(
ghPath,
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision",
],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
);
const pr = JSON.parse(stdout.trim());
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
return { status: null, githubFeaturesEnabled: true };
}
const mergedAt =
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0 ? pr.mergedAt : null;
const state =
mergedAt !== null
? "merged"
: typeof pr.state === "string" && pr.state.trim().length > 0
? pr.state.toLowerCase()
: "";
const checks = parseStatusCheckRollup(pr.statusCheckRollup);
const reviewDecision = mapReviewDecision(pr.reviewDecision);
return {
status: {
url: pr.url,
title: pr.title,
state,
baseRefName: pr.baseRefName ?? "",
headRefName: pr.headRefName ?? head,
isMerged: mergedAt !== null,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision,
},
status,
githubFeaturesEnabled: true,
};
} catch (error) {
if (isGhAuthError(error)) {
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
return { status: null, githubFeaturesEnabled: false };
}
// gh pr view exits non-zero when no PR exists for the branch
const message = error instanceof Error ? error.message : String(error);
if (message.includes("no pull requests found") || message.includes("Could not resolve")) {
return { status: null, githubFeaturesEnabled: true };
}
throw error;
}
}
@@ -2256,96 +1940,86 @@ export interface GitHubSearchResult {
githubFeaturesEnabled: boolean;
}
interface SearchGitHubIssuesAndPrsOptions {
kinds?: GitHubSearchKind[];
}
export async function searchGitHubIssuesAndPrs(
cwd: string,
query: string,
limit = 20,
github: GitHubService = createGitHubService(),
options: SearchGitHubIssuesAndPrsOptions = {},
): Promise<GitHubSearchResult> {
await requireGitRepo(cwd);
let ghPath: string;
try {
ghPath = await resolveGhPath();
} catch {
return { items: [], githubFeaturesEnabled: false };
}
try {
const [issuesResult, prsResult] = await Promise.allSettled([
execCommand(
ghPath,
[
"issue",
"list",
"--search",
query,
"--json",
"number,title,url,state,body,labels",
"--limit",
String(limit),
],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
),
execCommand(
ghPath,
[
"pr",
"list",
"--search",
query,
"--json",
"number,title,url,state,body,labels,baseRefName,headRefName",
"--limit",
String(limit),
],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
),
]);
const kinds = options.kinds ?? ["github-issue", "github-pr"];
const shouldFetchIssues = kinds.includes("github-issue");
const shouldFetchPullRequests = kinds.includes("github-pr");
const issuesResult = shouldFetchIssues
? await github.listIssues({ cwd, query, limit }).then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
: null;
const prsResult = shouldFetchPullRequests
? await github.listPullRequests({ cwd, query, limit }).then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
: null;
const items: GitHubSearchResult["items"] = [];
const requestedResults = [issuesResult, prsResult].filter((result) => result !== null);
const failures = requestedResults.filter((result) => result.status === "rejected");
if (
requestedResults.length > 0 &&
failures.length === requestedResults.length &&
failures.every(
(result) =>
result.status === "rejected" &&
(result.reason instanceof GitHubCliMissingError ||
result.reason instanceof GitHubAuthenticationError),
)
) {
return { items: [], githubFeaturesEnabled: false };
}
if (issuesResult.status === "fulfilled") {
const parsed = JSON.parse(issuesResult.value.stdout.trim() || "[]");
if (Array.isArray(parsed)) {
for (const item of parsed) {
items.push({
kind: "issue",
number: item.number,
title: item.title ?? "",
url: item.url ?? "",
state: item.state ?? "",
body: item.body ?? null,
labels: Array.isArray(item.labels)
? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean)
: [],
baseRefName: item.baseRefName ?? null,
headRefName: item.headRefName ?? null,
});
}
if (issuesResult?.status === "fulfilled") {
for (const item of issuesResult.value) {
items.push({
kind: "issue",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: null,
headRefName: null,
});
}
}
if (prsResult.status === "fulfilled") {
const parsed = JSON.parse(prsResult.value.stdout.trim() || "[]");
if (Array.isArray(parsed)) {
for (const item of parsed) {
items.push({
kind: "pr",
number: item.number,
title: item.title ?? "",
url: item.url ?? "",
state: item.state ?? "",
body: item.body ?? null,
labels: Array.isArray(item.labels)
? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean)
: [],
});
}
if (prsResult?.status === "fulfilled") {
for (const item of prsResult.value) {
items.push({
kind: "pr",
number: item.number,
title: item.title,
url: item.url,
state: item.state,
body: item.body,
labels: item.labels,
baseRefName: item.baseRefName,
headRefName: item.headRefName,
});
}
}
return { items, githubFeaturesEnabled: true };
} catch (error) {
if (isGhAuthError(error)) {
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
return { items: [], githubFeaturesEnabled: false };
}
throw error;
+160 -33
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree,
BranchAlreadyCheckedOutError,
createWorktree as createWorktreePrimitive,
deriveWorktreeProjectHash,
deletePaseoWorktree,
getScriptConfigs,
@@ -12,6 +13,8 @@ import {
type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process";
@@ -28,6 +31,35 @@ import { dirname, join } from "path";
import { tmpdir } from "os";
import net from "node:net";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
describe.skipIf(process.platform === "win32")("createWorktree", () => {
let tempDir: string;
let repoDir: string;
@@ -55,7 +87,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("creates a worktree for the current branch (main)", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -86,7 +118,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git add .", { cwd: varRepoDir });
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: varRepoDir,
baseBranch: "main",
@@ -113,7 +145,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("reports repoRoot as the repository root for paseo-owned worktrees", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -138,25 +170,120 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("creates a worktree with a new branch", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({
branchName: "feature-branch",
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "my-feature",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
runSetup: true,
paseoHome,
});
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature"));
expect(existsSync(result.worktreePath)).toBe(true);
// Verify branch was created
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("feature-branch");
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("feature/x");
execSync("git merge-base --is-ancestor main HEAD", { cwd: result.worktreePath });
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
it("checks out an existing local branch that is not checked out elsewhere", async () => {
execSync("git branch dev", { cwd: repoDir });
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "dev" },
runSetup: true,
paseoHome,
});
expect(existsSync(result.worktreePath)).toBe(true);
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("dev");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
});
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
let caughtError: unknown;
try {
await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "main" },
runSetup: true,
paseoHome,
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError);
expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main");
});
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execSync(`git clone --bare ${repoDir} ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir });
execSync('git config user.name "Test"', { cwd: remoteCloneDir });
execSync("git checkout -b contributor/feature", { cwd: remoteCloneDir });
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
writeFileSync(
join(remoteCloneDir, "paseo.json"),
JSON.stringify({ worktree: { setup: ['echo "setup ran" > setup.log'] } }),
);
execSync("git add .", { cwd: remoteCloneDir });
execSync('git -c commit.gpgsign=false commit -m "pr branch"', { cwd: remoteCloneDir });
const prHead = execSync("git rev-parse HEAD", { cwd: remoteCloneDir }).toString().trim();
execSync("git push origin contributor/feature", { cwd: remoteCloneDir });
execSync(`git --git-dir=${remoteDir} update-ref refs/pull/42/head ${prHead}`);
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "pr-42",
source: {
kind: "checkout-github-pr",
githubPrNumber: 42,
headRef: "user/feature",
baseRefName: "main",
},
runSetup: true,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n");
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("user/feature");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ baseRefName: "main" });
});
it("prefers origin/{branch} over local {branch} when both exist", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
@@ -181,7 +308,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git fetch origin", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "prefer-origin-feature",
cwd: repoDir,
baseBranch: "main",
@@ -198,7 +325,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git add file.txt", { cwd: repoDir });
execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "prefer-local-fallback-feature",
cwd: repoDir,
baseBranch: "main",
@@ -212,7 +339,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "missing-base-feature",
cwd: repoDir,
baseBranch: "does-not-exist",
@@ -225,7 +352,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("fails with invalid branch name", async () => {
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "INVALID_UPPERCASE",
cwd: repoDir,
baseBranch: "main",
@@ -239,7 +366,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
// Create a branch named "hello" first
execSync("git branch hello", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -260,7 +387,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git branch hello", { cwd: repoDir });
execSync("git branch hello-1", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -292,7 +419,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
cwd: repoDir,
});
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -326,7 +453,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
cwd: repoDir,
});
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -367,7 +494,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("reuses persisted worktree runtime port across resolutions", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -389,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("fails runtime env resolution when persisted port is in use", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -443,7 +570,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -616,14 +743,14 @@ describe("paseo worktree manager", () => {
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo });
}
const fromRepoA = await createWorktree({
const fromRepoA = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoA,
baseBranch: "main",
worktreeSlug: "alpha",
paseoHome,
});
const fromRepoB = await createWorktree({
const fromRepoB = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoB,
baseBranch: "main",
@@ -643,14 +770,14 @@ describe("paseo worktree manager", () => {
});
it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => {
const first = await createWorktree({
const first = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "alpha",
paseoHome,
});
const second = await createWorktree({
const second = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -670,7 +797,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a paseo worktree even when given a subdirectory path", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -708,7 +835,7 @@ describe("paseo worktree manager", () => {
},
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-branch",
cwd: repoDir,
baseBranch: "main",
@@ -745,7 +872,7 @@ describe("paseo worktree manager", () => {
{ cwd: repoDir },
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-port-missing-branch",
cwd: repoDir,
baseBranch: "main",
@@ -774,7 +901,7 @@ describe("paseo worktree manager", () => {
{ cwd: repoDir },
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-failure-branch",
cwd: repoDir,
baseBranch: "main",
@@ -791,7 +918,7 @@ describe("paseo worktree manager", () => {
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "orphan-admin-branch",
cwd: repoDir,
baseBranch: "main",
@@ -835,7 +962,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a worktree whose .git admin dir has already been removed", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "orphan-delete-branch",
cwd: repoDir,
baseBranch: "main",
@@ -859,7 +986,7 @@ describe("paseo worktree manager", () => {
});
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "idempotent-delete-branch",
cwd: repoDir,
baseBranch: "main",
@@ -881,7 +1008,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a worktree when the parent repo root is not available", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "no-cwd-branch",
cwd: repoDir,
baseBranch: "main",
+233 -85
View File
@@ -6,7 +6,6 @@ import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import { createNameId } from "mnemonic-id";
import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import {
@@ -153,15 +152,52 @@ export type PaseoWorktreeOwnership = {
worktreePath?: string;
};
interface CreateWorktreeOptions {
branchName: string;
export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
| { kind: "checkout-branch"; branchName: string }
| {
kind: "checkout-github-pr";
githubPrNumber: number;
headRef: string;
baseRefName: string;
};
export interface CreateWorktreeOptions {
cwd: string;
baseBranch: string;
worktreeSlug?: string;
runSetup?: boolean;
worktreeSlug: string;
source: WorktreeSource;
runSetup: boolean;
paseoHome?: string;
}
interface ResolveExistingWorktreeForSlugOptions {
slug: string;
repoRoot: string;
paseoHome?: string;
}
export class BranchAlreadyCheckedOutError extends Error {
readonly branchName: string;
constructor(branchName: string) {
super(`Branch already checked out: ${branchName}`);
this.name = "BranchAlreadyCheckedOutError";
this.branchName = branchName;
}
}
export class UnknownBranchError extends Error {
readonly branchName: string;
readonly cwd: string;
constructor(params: { branchName: string; cwd: string }) {
super(`Unknown branch: ${params.branchName}`);
this.name = "UnknownBranchError";
this.branchName = params.branchName;
this.cwd = params.cwd;
}
}
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
const paseoConfigPath = join(repoRoot, "paseo.json");
if (!existsSync(paseoConfigPath)) {
@@ -792,10 +828,6 @@ export function slugify(input: string): string {
return truncated.replace(/-+$/, "");
}
function generateWorktreeSlug(): string {
return createNameId();
}
const WORKTREE_PROJECT_HASH_LENGTH = 8;
function deriveShortAlphanumericHash(value: string): string {
@@ -974,6 +1006,36 @@ export async function listPaseoWorktrees({
}));
}
export async function resolveExistingWorktreeForSlug({
slug,
repoRoot,
paseoHome,
}: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null> {
const worktrees = await listPaseoWorktrees({
cwd: repoRoot,
paseoHome,
});
const slugSuffix = `${sep}${slug}`;
const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
if (!existingWorktree) {
return null;
}
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: existingWorktree.path,
env: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (!branchName) {
throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`);
}
return {
branchName,
worktreePath: existingWorktree.path,
};
}
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: { paseoHome?: string },
@@ -1099,8 +1161,11 @@ async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
@@ -1136,80 +1201,17 @@ async function removeDirectoryWithRetries(path: string): Promise<void> {
/**
* Create a git worktree with proper naming conventions
*/
export async function createWorktree({
branchName,
export const createWorktree = async ({
cwd,
baseBranch,
source,
worktreeSlug,
runSetup = true,
runSetup,
paseoHome,
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
// Validate branch name
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
const normalizedBaseBranch = baseBranch ? normalizeBaseRefName(baseBranch) : "";
if (!normalizedBaseBranch) {
throw new Error("Base branch is required when creating a Paseo worktree");
}
if (normalizedBaseBranch === "HEAD") {
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
// Resolve the base branch - prefer origin/{branch}, then fall back to local
let resolvedBaseBranch = normalizedBaseBranch;
try {
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
} catch {
try {
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
}
let worktreePath: string;
const desiredSlug = worktreeSlug || generateWorktreeSlug();
worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), desiredSlug);
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug);
mkdirSync(dirname(worktreePath), { recursive: true });
// Check if branch already exists
let branchExists = false;
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
});
branchExists = true;
} catch {
branchExists = false;
}
// Always create a new branch for the worktree
// If branchName already exists, use it as base and create worktree-slug as branch name
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName;
// Find unique branch name if collision
let newBranchName = candidateBranch;
let suffix = 1;
while (true) {
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], {
cwd,
});
// Branch exists, try with suffix
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
} catch {
break;
}
}
// Also handle worktree path collision
let finalWorktreePath = worktreePath;
let pathSuffix = 1;
@@ -1218,24 +1220,170 @@ export async function createWorktree({
pathSuffix++;
}
await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], {
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
cwd,
timeout: 120_000,
});
worktreePath = normalizePathForOwnership(finalWorktreePath);
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
if (runSetup) {
await runWorktreeSetupCommands({
worktreePath,
branchName: newBranchName,
branchName: sourcePlan.branchName,
cleanupOnFailure: true,
});
}
return {
branchName: newBranchName,
branchName: sourcePlan.branchName,
worktreePath,
};
};
interface ResolveWorktreeSourcePlanOptions {
cwd: string;
source: WorktreeSource;
desiredSlug: string;
}
interface WorktreeSourcePlan {
branchName: string;
metadataBaseRefName: string;
addArguments: string[];
}
async function resolveWorktreeSourcePlan({
cwd,
source,
desiredSlug,
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
switch (source.kind) {
case "branch-off": {
const branchName = source.newBranchName;
validateWorktreeBranchName(branchName);
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
const branchExists = await localBranchExists(cwd, branchName);
const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName;
const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch);
return {
branchName: newBranchName,
metadataBaseRefName: normalizedBaseBranch,
addArguments: ["-b", newBranchName, base],
};
}
case "checkout-branch": {
validateWorktreeBranchName(source.branchName);
if (!(await localBranchExists(cwd, source.branchName))) {
try {
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
cwd,
timeout: 120_000,
});
} catch {
throw new UnknownBranchError({ branchName: source.branchName, cwd });
}
}
if (await isBranchCheckedOut(cwd, source.branchName)) {
throw new BranchAlreadyCheckedOutError(source.branchName);
}
return {
branchName: source.branchName,
metadataBaseRefName: source.branchName,
addArguments: [source.branchName],
};
}
case "checkout-github-pr": {
validateWorktreeBranchName(source.headRef);
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
await runGitCommand(
[
"fetch",
"origin",
`refs/pull/${source.githubPrNumber}/head:refs/heads/${source.headRef}`,
"--force",
],
{
cwd,
timeout: 120_000,
},
);
return {
branchName: source.headRef,
metadataBaseRefName: normalizedBaseRefName,
addArguments: [source.headRef],
};
}
}
}
function validateWorktreeBranchName(branchName: string): void {
const validation = validateBranchSlug(branchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
function normalizeRequiredBaseBranch(baseBranch: string): string {
const normalizedBaseBranch = normalizeBaseRefName(baseBranch);
if (!normalizedBaseBranch) {
throw new Error("Base branch is required when creating a Paseo worktree");
}
if (normalizedBaseBranch === "HEAD") {
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
return normalizedBaseBranch;
}
async function resolveBaseBranchForWorktree(
cwd: string,
normalizedBaseBranch: string,
): Promise<string> {
try {
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
return `origin/${normalizedBaseBranch}`;
} catch {
try {
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
return normalizedBaseBranch;
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
}
}
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
});
return true;
} catch {
return false;
}
}
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
let newBranchName = candidateBranch;
let suffix = 1;
while (await localBranchExists(cwd, newBranchName)) {
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
}
return newBranchName;
}
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
return parseWorktreeList(stdout).some((entry) => entry.branchName === branchName);
}