feat: support creating worktrees from GitHub pull requests
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }] });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user