mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix worktree creation and archive flow
Server: drop the synchronous branch-name generator dep, use the worktree slug as the initial branch, and move first-agent branch auto-naming to an async post-creation step backed by a structured LLM call. Guards against renaming when the placeholder branch has already been changed. App: make workspace and worktree archive optimistic, rolling back the local hide if the daemon call fails, and suppress incoming workspace upserts while an archive is pending.
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -38762,6 +38762,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
|
||||
@@ -104,9 +104,14 @@ import {
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
|
||||
toast.error(message);
|
||||
});
|
||||
})();
|
||||
}, [
|
||||
archiveWorktree,
|
||||
isArchiving,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.workspaceDirectory,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2120,13 +2144,28 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -60,7 +59,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
@@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: String(message.payload.id),
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
if (
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
serverId,
|
||||
workspace,
|
||||
isArchivePending: isLocalWorktreeArchivePending,
|
||||
})
|
||||
) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const baseWorkspace: WorkspaceDescriptor = {
|
||||
id: "/repo/worktree",
|
||||
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return { ...baseWorkspace, ...input };
|
||||
}
|
||||
|
||||
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
|
||||
it("suppresses archiving upserts for a locally pending archive", () => {
|
||||
const isArchivePending = vi.fn(() => true);
|
||||
describe("workspace archive pending suppression", () => {
|
||||
it("tracks a locally pending workspace archive by id and directory", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
isArchivePending,
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isArchivePending).toHaveBeenCalledWith({
|
||||
serverId: "server-1",
|
||||
cwd: "/repo/worktree",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows archiving upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
isArchivePending: () => false,
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows normal upserts while a local archive is pending", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
isArchivePending: () => true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses upserts for a locally pending archive", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
|
||||
it("allows upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses stale normal upserts while a local archive is pending", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,96 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
|
||||
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
|
||||
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
|
||||
}
|
||||
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
|
||||
export function clearWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return;
|
||||
}
|
||||
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
|
||||
if (archives.size === 0) {
|
||||
pendingWorkspaceArchivesByServer.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.workspace.archivingAt !== null &&
|
||||
input.isArchivePending({
|
||||
serverId: input.serverId,
|
||||
cwd: input.workspace.workspaceDirectory,
|
||||
})
|
||||
);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => {
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: "ws-feature",
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
@@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
worktreePath: string;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
}) ?? input.worktreePath;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId });
|
||||
return {
|
||||
workspace:
|
||||
useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ??
|
||||
null,
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||
@@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
|
||||
});
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({ serverId, worktreePath });
|
||||
removeWorktreeFromSessionStore({
|
||||
serverId,
|
||||
worktreePath: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||
});
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
import {
|
||||
createAgentLastActivityCoalescer,
|
||||
type AgentLastActivityCommitter,
|
||||
@@ -1114,11 +1115,15 @@ export const useSessionStore = create<SessionStore>()(
|
||||
removeWorkspace: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.workspaces.has(workspaceId)) {
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: session?.workspaces,
|
||||
workspaceId,
|
||||
});
|
||||
if (!session || !workspaceKey) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.workspaces);
|
||||
next.delete(workspaceId);
|
||||
next.delete(workspaceKey);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
|
||||
@@ -38,7 +38,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type CreatePaseoWorktreeInput,
|
||||
} from "../paseo-worktree-service.js";
|
||||
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
|
||||
@@ -307,9 +306,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
|
||||
return async (input, serviceOptions) => {
|
||||
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
github,
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
|
||||
@@ -90,7 +90,6 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { createGitHubService } from "../services/github-service.js";
|
||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.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";
|
||||
@@ -575,9 +574,8 @@ export async function createPaseoDaemon(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
...coreDeps,
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
|
||||
@@ -33,7 +33,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
createPaseoWorktree,
|
||||
type CreatePaseoWorktreeDeps,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
@@ -105,32 +104,32 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
|
||||
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("unnamed-placeholder");
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
|
||||
const first = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Build the agent context name" },
|
||||
generateBranchName: deps.generateBranchName,
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.prompt ? "renamed-from-agent-context" : null,
|
||||
});
|
||||
const branchAfterFirst = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
@@ -149,14 +148,14 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "unnamed-placeholder",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
|
||||
const second = await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Try another name" },
|
||||
generateBranchName: () => "second-agent-name",
|
||||
generateBranchNameFromContext: async () => "second-agent-name",
|
||||
});
|
||||
const branchAfterSecond = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
@@ -172,10 +171,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
test("renames the branch even when the app supplies a random placeholder slug", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) =>
|
||||
seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
@@ -188,6 +184,18 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(created.workspace.displayName).toBe("dazzling-yak");
|
||||
|
||||
await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Investigate the failing login flow" },
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.prompt === "Investigate the failing login flow"
|
||||
? "renamed-from-prompt"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
@@ -195,20 +203,13 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(created.worktree.branchName).toBe("renamed-from-prompt");
|
||||
expect(branchAfter).toBe("renamed-from-prompt");
|
||||
expect(created.workspace.displayName).toBe("renamed-from-prompt");
|
||||
});
|
||||
|
||||
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const deps = createDeps({
|
||||
generateBranchName: (seed) =>
|
||||
seed?.includes("Investigate flaky checkout test")
|
||||
? "renamed-from-pr-attachment"
|
||||
: (seed ?? "fallback"),
|
||||
});
|
||||
const deps = createDeps();
|
||||
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
@@ -231,6 +232,27 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
|
||||
await attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: {
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 42,
|
||||
title: "Investigate flaky checkout test",
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
},
|
||||
],
|
||||
},
|
||||
generateBranchNameFromContext: async ({ firstAgentContext }) =>
|
||||
firstAgentContext.attachments?.[0]?.type === "github_pr"
|
||||
? "renamed-from-pr-attachment"
|
||||
: null,
|
||||
});
|
||||
|
||||
const branchAfter = execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
@@ -238,9 +260,46 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(created.worktree.branchName).toBe("renamed-from-pr-attachment");
|
||||
expect(branchAfter).toBe("renamed-from-pr-attachment");
|
||||
expect(created.workspace.displayName).toBe("renamed-from-pr-attachment");
|
||||
});
|
||||
|
||||
test("leaves the branch alone when generated branch text is invalid", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const created = await createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "dazzling-yak",
|
||||
firstAgentContext: { prompt: "Name this branch" },
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Name this branch" },
|
||||
generateBranchNameFromContext: async () => "Invalid Branch Name",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: true, renamed: false, branchName: null });
|
||||
|
||||
expect(
|
||||
execSync("git branch --show-current", {
|
||||
cwd: created.worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
|
||||
@@ -271,7 +330,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Rename checkout branch" },
|
||||
generateBranchName: () => "must-not-rename",
|
||||
generateBranchNameFromContext: async () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
@@ -307,7 +366,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
firstAgentContext: { prompt: "Rename PR checkout" },
|
||||
generateBranchName: () => "must-not-rename",
|
||||
generateBranchNameFromContext: async () => "must-not-rename",
|
||||
}),
|
||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||
expect(
|
||||
@@ -350,15 +409,13 @@ function createDeps(options?: {
|
||||
events?: string[];
|
||||
projects?: Map<string, PersistedProjectRecord>;
|
||||
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
||||
generateBranchName?: (seed: string | undefined) => string;
|
||||
}): TestDeps {
|
||||
const events = options?.events ?? [];
|
||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
||||
|
||||
return {
|
||||
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
||||
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
|
||||
github: createGitHubServiceStub(),
|
||||
projects,
|
||||
workspaces,
|
||||
projectRegistry: {
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
type CreateWorktreeCoreDeps,
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import type { WorktreeConfig } from "../utils/worktree.js";
|
||||
import { validateBranchSlug } from "../utils/worktree.js";
|
||||
import { renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
readPaseoWorktreeMetadata,
|
||||
@@ -24,7 +23,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
||||
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
@@ -58,25 +57,18 @@ export async function createPaseoWorktree(
|
||||
deps: CreatePaseoWorktreeDeps,
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
if (!buildAgentBranchNameSeed(input.firstAgentContext)) {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
}
|
||||
const worktree = await maybeAutoNameCreatedWorktree({
|
||||
input,
|
||||
createdWorktree,
|
||||
deps,
|
||||
});
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree,
|
||||
worktree: createdWorktree.worktree,
|
||||
deps,
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: worktree.worktreePath });
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree,
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
@@ -87,11 +79,15 @@ export async function createPaseoWorktree(
|
||||
export async function attemptFirstAgentBranchAutoName(options: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
generateBranchNameFromContext: (input: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => Promise<string | null>;
|
||||
getCurrentBranch?: typeof getCurrentBranch;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
||||
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
|
||||
if (!seed) {
|
||||
const firstAgentContext = options.firstAgentContext;
|
||||
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
@@ -109,11 +105,27 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
|
||||
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = options.generateBranchName(seed);
|
||||
const branchName = await options.generateBranchNameFromContext({
|
||||
cwd: options.cwd,
|
||||
firstAgentContext,
|
||||
});
|
||||
if (!branchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
|
||||
if (!validation.valid || branchName === placeholderBranchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
@@ -139,33 +151,6 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeAutoNameCreatedWorktree(options: {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
|
||||
}): Promise<WorktreeConfig> {
|
||||
const { input, createdWorktree, deps } = options;
|
||||
const seed = buildAgentBranchNameSeed(input.firstAgentContext);
|
||||
if (!seed || !createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const branchName = deps.generateBranchName(seed);
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
|
||||
return createdWorktree.worktree;
|
||||
}
|
||||
|
||||
const renamedBranch = await renameCurrentBranch(
|
||||
createdWorktree.worktree.worktreePath,
|
||||
branchName,
|
||||
);
|
||||
return {
|
||||
...createdWorktree.worktree,
|
||||
branchName: renamedBranch.currentBranch ?? branchName,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ResolverHarness {
|
||||
github: GitHubService;
|
||||
headRefLookups: GitHubHeadRefLookup[];
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
function createResolverHarness(): ResolverHarness {
|
||||
@@ -51,7 +50,6 @@ function createResolverHarness(): ResolverHarness {
|
||||
github,
|
||||
headRefLookups,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
generateBranchName: (seed) => seed ?? "generated-worktree",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,10 +59,12 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
test("branches off the repo default branch when no explicit fields are set", async () => {
|
||||
const deps = createResolverHarness();
|
||||
|
||||
await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({
|
||||
await expect(
|
||||
resolveWorktreeCreationIntent({ worktreeSlug: "generated-worktree" }, repoRoot, deps),
|
||||
).resolves.toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "generated-worktree",
|
||||
branchName: "generated-worktree",
|
||||
});
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe("resolveWorktreeCreationIntent", () => {
|
||||
).resolves.toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "dev",
|
||||
newBranchName: "feature",
|
||||
branchName: "feature",
|
||||
});
|
||||
expect(deps.headRefLookups).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -3,17 +3,29 @@ import type { WorktreeSource } from "../utils/worktree.js";
|
||||
|
||||
export type WorktreeCreationIntent = WorktreeSource;
|
||||
|
||||
export interface ResolveWorktreeCreationIntentInput {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
export type ResolveWorktreeCreationIntentInput =
|
||||
| {
|
||||
worktreeSlug: string;
|
||||
refName?: string;
|
||||
action?: "branch-off";
|
||||
githubPrNumber?: undefined;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action: "checkout";
|
||||
githubPrNumber?: number;
|
||||
}
|
||||
| {
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: undefined;
|
||||
githubPrNumber: number;
|
||||
};
|
||||
|
||||
export interface ResolveWorktreeCreationIntentDeps {
|
||||
github: GitHubService;
|
||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
export class MissingCheckoutTargetError extends Error {
|
||||
@@ -34,7 +46,7 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,14 +84,14 @@ export async function resolveWorktreeCreationIntent(
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: input.refName.trim(),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "branch-off",
|
||||
baseBranch: await resolveDefaultBranch(repoRoot, deps),
|
||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
||||
branchName: input.worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ import {
|
||||
type CreatePaseoWorktreeInput,
|
||||
type CreatePaseoWorktreeResult,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
||||
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
|
||||
import {
|
||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||
@@ -3002,7 +3002,7 @@ export class Session {
|
||||
if (!resolvedWorkspace) {
|
||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||
}
|
||||
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
|
||||
this.scheduleAutoNameWorkspaceBranchForFirstAgent({
|
||||
workspace: resolvedWorkspace,
|
||||
firstAgentContext,
|
||||
});
|
||||
@@ -3394,15 +3394,35 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): void {
|
||||
setTimeout(() => {
|
||||
void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd: input.workspace.cwd },
|
||||
"Failed to auto-name worktree branch",
|
||||
);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
const result = await attemptFirstAgentBranchAutoName({
|
||||
cwd: input.workspace.cwd,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
generateBranchName: coreDeps.generateBranchName,
|
||||
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
|
||||
return generateBranchNameFromFirstAgentContext({
|
||||
agentManager: this.agentManager,
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!result.renamed || !result.branchName) {
|
||||
return input.workspace;
|
||||
@@ -6373,9 +6393,8 @@ export class Session {
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
},
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
||||
const result = await createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
github: this.github,
|
||||
...(options?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
@@ -6970,6 +6989,8 @@ export class Session {
|
||||
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
|
||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||
|
||||
@@ -55,7 +55,7 @@ async function createBootstrapWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
test("calls the structured generator with first-agent prompt text", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("fix-login-flow");
|
||||
expect(generateStructured).toHaveBeenCalledTimes(1);
|
||||
expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
agentConfigOverrides: {
|
||||
title: "Branch name generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow");
|
||||
});
|
||||
|
||||
test("uses attachment-only context", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" }));
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: {
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 42,
|
||||
title: "Review flaky checkout",
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
},
|
||||
],
|
||||
},
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("review-flaky-checkout");
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout");
|
||||
});
|
||||
});
|
||||
81
packages/server/src/server/worktree-branch-name-generator.ts
Normal file
81
packages/server/src/server/worktree-branch-name-generator.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
|
||||
interface BranchNameGeneratorLogger {
|
||||
warn: (obj: object, msg?: string) => void;
|
||||
error: (obj: object, msg?: string) => void;
|
||||
}
|
||||
|
||||
export interface GenerateBranchNameFromFirstAgentContextOptions {
|
||||
agentManager: AgentManager;
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
logger: BranchNameGeneratorLogger;
|
||||
deps?: {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
};
|
||||
}
|
||||
|
||||
const BranchNameSchema = z.object({
|
||||
branch: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
function buildPrompt(seed: string): string {
|
||||
return [
|
||||
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
|
||||
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
|
||||
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
|
||||
"Return JSON only with a single field 'branch'.",
|
||||
"",
|
||||
"User context:",
|
||||
seed,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function generateBranchNameFromFirstAgentContext(
|
||||
options: GenerateBranchNameFromFirstAgentContextOptions,
|
||||
): Promise<string | null> {
|
||||
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
|
||||
if (!seed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
|
||||
try {
|
||||
const result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(seed),
|
||||
schema: BranchNameSchema,
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Branch name generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.branch.trim() || null;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
options.logger.warn({ err: error }, "Structured branch name generation failed");
|
||||
return null;
|
||||
}
|
||||
options.logger.error({ err: error }, "Branch name generation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -44,17 +44,13 @@ function createGitHubServiceStub(): GitHubService {
|
||||
};
|
||||
}
|
||||
|
||||
function createCoreDeps(options?: {
|
||||
github?: GitHubService;
|
||||
generateBranchName?: (seed: string | undefined) => string;
|
||||
}) {
|
||||
function createCoreDeps(options?: { github?: GitHubService }) {
|
||||
return {
|
||||
github: options?.github ?? createGitHubServiceStub(),
|
||||
workspaceGitService: {
|
||||
resolveRepoRoot: async (cwd: string) => cwd,
|
||||
},
|
||||
resolveDefaultBranch: async () => "main",
|
||||
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,13 +187,33 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "legacy-rpc",
|
||||
branchName: "legacy-rpc",
|
||||
});
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.worktree.branchName).toBe("legacy-rpc");
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("creates a branch-off worktree with a mnemonic slug when no slug is supplied", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
const result = await createCoreWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
},
|
||||
createCoreDeps(),
|
||||
);
|
||||
|
||||
expect(result.intent.kind).toBe("branch-off");
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.worktree.branchName).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
|
||||
expect(result.worktree.branchName).toBe(path.basename(result.worktree.worktreePath));
|
||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
|
||||
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -259,7 +275,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "mcp-standalone",
|
||||
branchName: "mcp-standalone",
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("mcp-standalone");
|
||||
});
|
||||
@@ -290,7 +306,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "dev",
|
||||
newBranchName: "from-dev",
|
||||
branchName: "from-dev",
|
||||
});
|
||||
expect(mergeBase).toBe(devTip);
|
||||
});
|
||||
@@ -500,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
||||
expect(result.intent).toEqual({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "agent-worktree",
|
||||
branchName: "agent-worktree",
|
||||
});
|
||||
expect(result.worktree.branchName).toBe("agent-worktree");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
@@ -16,8 +16,12 @@ import {
|
||||
import type { FirstAgentContext } from "../shared/messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
||||
export interface CreateWorktreeCoreInput {
|
||||
cwd: string;
|
||||
worktreeSlug?: string;
|
||||
refName?: string;
|
||||
action?: "branch-off" | "checkout";
|
||||
githubPrNumber?: number;
|
||||
firstAgentContext?: FirstAgentContext;
|
||||
paseoHome?: string;
|
||||
runSetup?: boolean;
|
||||
@@ -27,7 +31,6 @@ export interface CreateWorktreeCoreDeps {
|
||||
github: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
generateBranchName: (seed: string | undefined) => string;
|
||||
}
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
@@ -42,31 +45,51 @@ export async function createWorktreeCore(
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
): Promise<CreateWorktreeCoreResult> {
|
||||
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
||||
const requestedSlug = input.worktreeSlug ? slugify(input.worktreeSlug) : undefined;
|
||||
const requestedWorktreeSlug = input.worktreeSlug
|
||||
? normalizeWorktreeSlug(input.worktreeSlug)
|
||||
: undefined;
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(
|
||||
{ ...input, worktreeSlug: requestedSlug },
|
||||
repoRoot,
|
||||
{
|
||||
...deps,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
},
|
||||
);
|
||||
let intentInput: ResolveWorktreeCreationIntentInput;
|
||||
if (input.action === "checkout") {
|
||||
intentInput = {
|
||||
action: "checkout",
|
||||
refName: input.refName,
|
||||
githubPrNumber: input.githubPrNumber,
|
||||
worktreeSlug: requestedWorktreeSlug,
|
||||
};
|
||||
} else if (input.githubPrNumber !== undefined) {
|
||||
intentInput = {
|
||||
githubPrNumber: input.githubPrNumber,
|
||||
refName: input.refName,
|
||||
worktreeSlug: requestedWorktreeSlug,
|
||||
};
|
||||
} else {
|
||||
const worktreeSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(createNameId());
|
||||
intentInput = {
|
||||
action: "branch-off",
|
||||
refName: input.refName,
|
||||
worktreeSlug,
|
||||
};
|
||||
}
|
||||
|
||||
const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, {
|
||||
...deps,
|
||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
||||
});
|
||||
let normalizedSlug: string;
|
||||
|
||||
switch (intent.kind) {
|
||||
case "branch-off": {
|
||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName));
|
||||
normalizedSlug = intent.branchName;
|
||||
break;
|
||||
}
|
||||
case "checkout-branch": {
|
||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName));
|
||||
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
|
||||
break;
|
||||
}
|
||||
case "checkout-github-pr": {
|
||||
normalizedSlug = validateWorktreeSlug(
|
||||
requestedSlug ?? slugify(intent.localBranchName ?? intent.headRef),
|
||||
);
|
||||
normalizedSlug =
|
||||
requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -94,13 +117,6 @@ export async function createWorktreeCore(
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorktreeCoreDeps(github: GitHubService): CreateWorktreeCoreDeps {
|
||||
return {
|
||||
github,
|
||||
generateBranchName: (seed) => slugify(seed ?? uuidv4()),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDefaultBranch(
|
||||
repoRoot: string,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
@@ -132,3 +148,7 @@ function validateWorktreeSlug(slug: string): string {
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function normalizeWorktreeSlug(value: string): string {
|
||||
return validateWorktreeSlug(slugify(value));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ 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 { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
@@ -65,7 +64,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -262,9 +261,8 @@ function createPaseoWorktreeForTest(options: {
|
||||
});
|
||||
|
||||
return (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
||||
return createPaseoWorktreeService(input, {
|
||||
...coreDeps,
|
||||
github: createGitHubServiceStub(),
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||
: {}),
|
||||
@@ -1303,7 +1301,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
intent: {
|
||||
kind: "branch-off" as const,
|
||||
baseBranch: "main",
|
||||
newBranchName: "fix-attached-pr-context",
|
||||
branchName: "fix-attached-pr-context",
|
||||
},
|
||||
workspace: {
|
||||
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
|
||||
@@ -1445,7 +1443,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
expect(result.intent).toMatchObject({
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
newBranchName: "resolver-feature",
|
||||
branchName: "resolver-feature",
|
||||
});
|
||||
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,10 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
|
||||
},
|
||||
) => Promise<CreatePaseoWorktreeResult>;
|
||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
autoNameWorkspaceBranchForFirstAgent?: (input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface AgentWorktreeSetupContinuationInput {
|
||||
@@ -576,6 +580,12 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
setTimeout(() => {
|
||||
if (input.firstAgentContext) {
|
||||
dependencies.autoNameWorkspaceBranchForFirstAgent?.({
|
||||
workspace,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
});
|
||||
}
|
||||
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
|
||||
@@ -64,7 +64,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -62,7 +62,7 @@ function createLegacyWorktreeForTest(
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: options.baseBranch,
|
||||
newBranchName: options.branchName,
|
||||
branchName: options.branchName,
|
||||
},
|
||||
runSetup: options.runSetup ?? true,
|
||||
paseoHome: options.paseoHome,
|
||||
@@ -182,7 +182,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "my-feature",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -796,7 +796,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "seed-uncommitted",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/seed" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -824,7 +824,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "preserve-committed",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/preserve" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
@@ -839,7 +839,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "no-config",
|
||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/no-config" },
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface PaseoWorktreeOwnership {
|
||||
}
|
||||
|
||||
export type WorktreeSource =
|
||||
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
|
||||
| { kind: "branch-off"; baseBranch: string; branchName: string }
|
||||
| { kind: "checkout-branch"; branchName: string }
|
||||
| {
|
||||
kind: "checkout-github-pr";
|
||||
@@ -1292,7 +1292,7 @@ async function resolveWorktreeSourcePlan({
|
||||
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
|
||||
switch (source.kind) {
|
||||
case "branch-off": {
|
||||
const branchName = source.newBranchName;
|
||||
const branchName = source.branchName;
|
||||
validateWorktreeBranchName(branchName);
|
||||
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
||||
|
||||
Reference in New Issue
Block a user