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",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
|||||||
@@ -104,9 +104,14 @@ import {
|
|||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||||
|
import {
|
||||||
|
clearWorkspaceArchivePending,
|
||||||
|
markWorkspaceArchivePending,
|
||||||
|
} from "@/contexts/session-workspace-upserts";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
requireWorkspaceExecutionDirectory,
|
requireWorkspaceExecutionDirectory,
|
||||||
|
resolveWorkspaceMapKeyByIdentity,
|
||||||
resolveWorkspaceExecutionDirectory,
|
resolveWorkspaceExecutionDirectory,
|
||||||
} from "@/utils/workspace-execution";
|
} from "@/utils/workspace-execution";
|
||||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
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 workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||||
|
|
||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
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 WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}, [
|
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||||
archiveWorktree,
|
|
||||||
isArchiving,
|
|
||||||
redirectAfterArchive,
|
|
||||||
toast,
|
|
||||||
workspace.name,
|
|
||||||
workspace.workspaceDirectory,
|
|
||||||
workspace.serverId,
|
|
||||||
workspace.workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
const handleArchiveWorkspace = useCallback(() => {
|
||||||
if (isArchivingWorkspace) {
|
if (isArchivingWorkspace) {
|
||||||
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
|
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||||
redirectAfterArchive();
|
redirectAfterArchive();
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
|
|||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
restoreOptimisticallyHiddenWorkspace({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||||
} finally {
|
} finally {
|
||||||
setIsArchivingWorkspace(false);
|
setIsArchivingWorkspace(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
}, [
|
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||||
isArchivingWorkspace,
|
|
||||||
redirectAfterArchive,
|
|
||||||
toast,
|
|
||||||
workspace.name,
|
|
||||||
workspace.serverId,
|
|
||||||
workspace.workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCopyPath = useCallback(() => {
|
const handleCopyPath = useCallback(() => {
|
||||||
let copyTargetDirectory: string;
|
let copyTargetDirectory: string;
|
||||||
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
|
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||||
redirectAfterArchive();
|
redirectAfterArchive();
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
restoreOptimisticallyHiddenWorkspace({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||||
} finally {
|
} finally {
|
||||||
setIsArchivingWorkspace(false);
|
setIsArchivingWorkspace(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
}, [
|
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||||
isArchivingWorkspace,
|
|
||||||
redirectAfterArchive,
|
|
||||||
toast,
|
|
||||||
workspace.name,
|
|
||||||
workspace.serverId,
|
|
||||||
workspace.workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2120,13 +2144,28 @@ function ProjectBlock({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsRemovingProject(true);
|
setIsRemovingProject(true);
|
||||||
|
const snapshots = new Map(
|
||||||
|
project.workspaces.map((workspace) => [
|
||||||
|
workspace.workspaceId,
|
||||||
|
hideWorkspaceOptimistically(workspace),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||||
void Promise.allSettled(
|
void Promise.allSettled(
|
||||||
project.workspaces.map(async (ws) => {
|
project.workspaces.map(async (ws) => {
|
||||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
try {
|
||||||
if (payload.error) {
|
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||||
throw new Error(payload.error);
|
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) => {
|
).then((results) => {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
|
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { sendOsNotification } from "@/utils/os-notifications";
|
import { sendOsNotification } from "@/utils/os-notifications";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||||
@@ -60,7 +59,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
|||||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
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 { isNative } from "@/constants/platform";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
@@ -543,6 +545,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||||
|
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
workspaces.set(workspace.id, workspace);
|
workspaces.set(workspace.id, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,18 +1244,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||||
if (message.type !== "workspace_update") return;
|
if (message.type !== "workspace_update") return;
|
||||||
if (message.payload.kind === "remove") {
|
if (message.payload.kind === "remove") {
|
||||||
|
clearWorkspaceArchivePending({
|
||||||
|
serverId,
|
||||||
|
workspaceId: String(message.payload.id),
|
||||||
|
});
|
||||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||||
removeWorkspace(serverId, String(message.payload.id));
|
removeWorkspace(serverId, String(message.payload.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||||
if (
|
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
|
||||||
serverId,
|
|
||||||
workspace,
|
|
||||||
isArchivePending: isLocalWorktreeArchivePending,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mergeWorkspaces(serverId, [workspace]);
|
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 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 = {
|
const baseWorkspace: WorkspaceDescriptor = {
|
||||||
id: "/repo/worktree",
|
id: "/repo/worktree",
|
||||||
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
|||||||
return { ...baseWorkspace, ...input };
|
return { ...baseWorkspace, ...input };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
|
describe("workspace archive pending suppression", () => {
|
||||||
it("suppresses archiving upserts for a locally pending archive", () => {
|
it("tracks a locally pending workspace archive by id and directory", () => {
|
||||||
const isArchivePending = vi.fn(() => true);
|
markWorkspaceArchivePending({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "/repo/worktree",
|
||||||
|
workspaceDirectory: "/repo/worktree",
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
isWorkspaceArchivePending({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
workspaceId: "/repo/worktree",
|
||||||
isArchivePending,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(isArchivePending).toHaveBeenCalledWith({
|
|
||||||
serverId: "server-1",
|
|
||||||
cwd: "/repo/worktree",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows archiving upserts when this client did not start the archive", () => {
|
|
||||||
expect(
|
expect(
|
||||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
isWorkspaceArchivePending({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspace: workspace(),
|
workspaceDirectory: "/repo/worktree",
|
||||||
isArchivePending: () => false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
it("allows normal upserts while a local archive is pending", () => {
|
|
||||||
expect(
|
expect(
|
||||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
shouldSuppressWorkspaceForLocalArchive({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspace: workspace({ archivingAt: null }),
|
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);
|
).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 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;
|
serverId: string;
|
||||||
workspace: WorkspaceDescriptor;
|
workspace: WorkspaceDescriptor;
|
||||||
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return isWorkspaceArchivePending({
|
||||||
input.workspace.archivingAt !== null &&
|
serverId: input.serverId,
|
||||||
input.isArchivePending({
|
workspaceId: input.workspace.id,
|
||||||
serverId: input.serverId,
|
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||||
cwd: input.workspace.workspaceDirectory,
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/stores/session-store-hooks";
|
} from "@/stores/session-store-hooks";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = [];
|
const EMPTY_ORDER: string[] = [];
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
});
|
});
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = toWorkspaceDescriptor(entry);
|
const workspace = toWorkspaceDescriptor(entry);
|
||||||
|
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
next.set(workspace.id, workspace);
|
next.set(workspace.id, workspace);
|
||||||
}
|
}
|
||||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
isLocalWorktreeArchivePending,
|
isLocalWorktreeArchivePending,
|
||||||
useCheckoutGitActionsStore,
|
useCheckoutGitActionsStore,
|
||||||
} from "@/stores/checkout-git-actions-store";
|
} from "@/stores/checkout-git-actions-store";
|
||||||
|
import {
|
||||||
|
clearWorkspaceArchivePending,
|
||||||
|
isWorkspaceArchivePending,
|
||||||
|
} from "@/contexts/session-workspace-upserts";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -53,6 +57,8 @@ describe("checkout-git-actions-store", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
|
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||||
|
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||||
appQueryClient.clear();
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||||
});
|
});
|
||||||
@@ -60,6 +66,8 @@ describe("checkout-git-actions-store", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
|
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
||||||
|
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
||||||
appQueryClient.clear();
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||||
});
|
});
|
||||||
@@ -213,6 +221,36 @@ describe("checkout-git-actions-store", () => {
|
|||||||
|
|
||||||
deferred.resolve({});
|
deferred.resolve({});
|
||||||
await archive;
|
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 () => {
|
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import {
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-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;
|
const SUCCESS_DISPLAY_MS = 1000;
|
||||||
|
|
||||||
@@ -169,10 +177,15 @@ function snapshotWorktreeArchiveState(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
}): WorktreeArchiveSnapshot {
|
}): 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 {
|
return {
|
||||||
workspace:
|
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||||
useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ??
|
|
||||||
null,
|
|
||||||
worktreeLists: appQueryClient.getQueriesData({
|
worktreeLists: appQueryClient.getQueriesData({
|
||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||||
@@ -437,14 +450,26 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
const client = resolveClient(serverId);
|
const client = resolveClient(serverId);
|
||||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||||
|
markWorkspaceArchivePending({
|
||||||
|
serverId,
|
||||||
|
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||||
|
workspaceDirectory: snapshot.workspace?.workspaceDirectory ?? worktreePath,
|
||||||
|
});
|
||||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||||
removeWorktreeFromSessionStore({ serverId, worktreePath });
|
removeWorktreeFromSessionStore({
|
||||||
|
serverId,
|
||||||
|
worktreePath: snapshot.workspace?.id ?? worktreePath,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
const payload = await client.archivePaseoWorktree({ worktreePath });
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error.message);
|
throw new Error(payload.error.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearWorkspaceArchivePending({
|
||||||
|
serverId,
|
||||||
|
workspaceId: snapshot.workspace?.id ?? worktreePath,
|
||||||
|
});
|
||||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
WorkspaceDescriptorPayload,
|
WorkspaceDescriptorPayload,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||||
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||||
import {
|
import {
|
||||||
createAgentLastActivityCoalescer,
|
createAgentLastActivityCoalescer,
|
||||||
type AgentLastActivityCommitter,
|
type AgentLastActivityCommitter,
|
||||||
@@ -1114,11 +1115,15 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
removeWorkspace: (serverId, workspaceId) => {
|
removeWorkspace: (serverId, workspaceId) => {
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const session = prev.sessions[serverId];
|
const session = prev.sessions[serverId];
|
||||||
if (!session || !session.workspaces.has(workspaceId)) {
|
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||||
|
workspaces: session?.workspaces,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
if (!session || !workspaceKey) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = new Map(session.workspaces);
|
const next = new Map(session.workspaces);
|
||||||
next.delete(workspaceId);
|
next.delete(workspaceKey);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function createLegacyWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: options.runSetup ?? true,
|
runSetup: options.runSetup ?? true,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
type CreatePaseoWorktreeInput,
|
type CreatePaseoWorktreeInput,
|
||||||
} from "../paseo-worktree-service.js";
|
} from "../paseo-worktree-service.js";
|
||||||
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||||
import { createWorktreeCoreDeps } from "../worktree-core.js";
|
|
||||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||||
import type { GitHubService } from "../../services/github-service.js";
|
import type { GitHubService } from "../../services/github-service.js";
|
||||||
|
|
||||||
@@ -307,9 +306,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
|
|
||||||
return async (input, serviceOptions) => {
|
return async (input, serviceOptions) => {
|
||||||
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
|
||||||
const result = await createPaseoWorktreeService(input, {
|
const result = await createPaseoWorktreeService(input, {
|
||||||
...coreDeps,
|
github,
|
||||||
...(serviceOptions?.resolveDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
|||||||
import { createGitHubService } from "../services/github-service.js";
|
import { createGitHubService } from "../services/github-service.js";
|
||||||
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
||||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||||
@@ -575,9 +574,8 @@ export async function createPaseoDaemon(
|
|||||||
{
|
{
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
|
||||||
return createRegisteredPaseoWorktree(workflowInput, {
|
return createRegisteredPaseoWorktree(workflowInput, {
|
||||||
...coreDeps,
|
github,
|
||||||
...(workflowOptions?.resolveDefaultBranch
|
...(workflowOptions?.resolveDefaultBranch
|
||||||
? {
|
? {
|
||||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function createLegacyWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: options.runSetup ?? true,
|
runSetup: options.runSetup ?? true,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
createPaseoWorktree,
|
createPaseoWorktree,
|
||||||
type CreatePaseoWorktreeDeps,
|
type CreatePaseoWorktreeDeps,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
|
||||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
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 () => {
|
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const deps = createDeps({
|
const deps = createDeps();
|
||||||
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await createPaseoWorktree(
|
const created = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "dazzling-yak",
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: path.join(tempDir, ".paseo"),
|
paseoHome: path.join(tempDir, ".paseo"),
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(created.worktree.branchName).toBe("unnamed-placeholder");
|
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||||
version: 2,
|
version: 2,
|
||||||
firstAgentBranchAutoName: {
|
firstAgentBranchAutoName: {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
placeholderBranchName: "unnamed-placeholder",
|
placeholderBranchName: "dazzling-yak",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = await attemptFirstAgentBranchAutoName({
|
const first = await attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
firstAgentContext: { prompt: "Build the agent context name" },
|
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", {
|
const branchAfterFirst = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
@@ -149,14 +148,14 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
|||||||
version: 2,
|
version: 2,
|
||||||
firstAgentBranchAutoName: {
|
firstAgentBranchAutoName: {
|
||||||
status: "attempted",
|
status: "attempted",
|
||||||
placeholderBranchName: "unnamed-placeholder",
|
placeholderBranchName: "dazzling-yak",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = await attemptFirstAgentBranchAutoName({
|
const second = await attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
firstAgentContext: { prompt: "Try another name" },
|
firstAgentContext: { prompt: "Try another name" },
|
||||||
generateBranchName: () => "second-agent-name",
|
generateBranchNameFromContext: async () => "second-agent-name",
|
||||||
});
|
});
|
||||||
const branchAfterSecond = execSync("git branch --show-current", {
|
const branchAfterSecond = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
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 () => {
|
test("renames the branch even when the app supplies a random placeholder slug", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const deps = createDeps({
|
const deps = createDeps();
|
||||||
generateBranchName: (seed) =>
|
|
||||||
seed === "Investigate the failing login flow" ? "renamed-from-prompt" : (seed ?? "fallback"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await createPaseoWorktree(
|
const created = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
@@ -188,6 +184,18 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
|||||||
deps,
|
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", {
|
const branchAfter = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
@@ -195,20 +203,13 @@ test("renames the branch even when the app supplies a random placeholder slug",
|
|||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
expect(created.worktree.branchName).toBe("renamed-from-prompt");
|
|
||||||
expect(branchAfter).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 () => {
|
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const deps = createDeps({
|
const deps = createDeps();
|
||||||
generateBranchName: (seed) =>
|
|
||||||
seed?.includes("Investigate flaky checkout test")
|
|
||||||
? "renamed-from-pr-attachment"
|
|
||||||
: (seed ?? "fallback"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await createPaseoWorktree(
|
const created = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
@@ -231,6 +232,27 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
|||||||
deps,
|
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", {
|
const branchAfter = execSync("git branch --show-current", {
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
@@ -238,9 +260,46 @@ test("renames the branch from a github_pr attachment when no prompt is supplied"
|
|||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
expect(created.worktree.branchName).toBe("renamed-from-pr-attachment");
|
|
||||||
expect(branchAfter).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 () => {
|
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({
|
attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
firstAgentContext: { prompt: "Rename checkout branch" },
|
firstAgentContext: { prompt: "Rename checkout branch" },
|
||||||
generateBranchName: () => "must-not-rename",
|
generateBranchNameFromContext: async () => "must-not-rename",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
@@ -307,7 +366,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
|||||||
attemptFirstAgentBranchAutoName({
|
attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
firstAgentContext: { prompt: "Rename PR checkout" },
|
firstAgentContext: { prompt: "Rename PR checkout" },
|
||||||
generateBranchName: () => "must-not-rename",
|
generateBranchNameFromContext: async () => "must-not-rename",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
|
||||||
expect(
|
expect(
|
||||||
@@ -350,15 +409,13 @@ function createDeps(options?: {
|
|||||||
events?: string[];
|
events?: string[];
|
||||||
projects?: Map<string, PersistedProjectRecord>;
|
projects?: Map<string, PersistedProjectRecord>;
|
||||||
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
workspaces?: Map<string, PersistedWorkspaceRecord>;
|
||||||
generateBranchName?: (seed: string | undefined) => string;
|
|
||||||
}): TestDeps {
|
}): TestDeps {
|
||||||
const events = options?.events ?? [];
|
const events = options?.events ?? [];
|
||||||
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
|
||||||
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...createWorktreeCoreDeps(createGitHubServiceStub()),
|
github: createGitHubServiceStub(),
|
||||||
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
|
|
||||||
projects,
|
projects,
|
||||||
workspaces,
|
workspaces,
|
||||||
projectRegistry: {
|
projectRegistry: {
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import {
|
|||||||
type CreateWorktreeCoreDeps,
|
type CreateWorktreeCoreDeps,
|
||||||
type CreateWorktreeCoreInput,
|
type CreateWorktreeCoreInput,
|
||||||
} from "./worktree-core.js";
|
} from "./worktree-core.js";
|
||||||
import type { WorktreeConfig } from "../utils/worktree.js";
|
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||||
import { validateBranchSlug } from "../utils/worktree.js";
|
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||||
import { renameCurrentBranch } from "../utils/checkout-git.js";
|
|
||||||
import {
|
import {
|
||||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||||
readPaseoWorktreeMetadata,
|
readPaseoWorktreeMetadata,
|
||||||
@@ -24,7 +23,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
|||||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||||
import type { FirstAgentContext } from "../shared/messages.js";
|
import type { FirstAgentContext } from "../shared/messages.js";
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
|
export type CreatePaseoWorktreeInput = CreateWorktreeCoreInput;
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeResult {
|
export interface CreatePaseoWorktreeResult {
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
@@ -58,25 +57,18 @@ export async function createPaseoWorktree(
|
|||||||
deps: CreatePaseoWorktreeDeps,
|
deps: CreatePaseoWorktreeDeps,
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
const createdWorktree = await createWorktreeCore(input, deps);
|
const createdWorktree = await createWorktreeCore(input, deps);
|
||||||
if (!buildAgentBranchNameSeed(input.firstAgentContext)) {
|
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
|
||||||
}
|
|
||||||
const worktree = await maybeAutoNameCreatedWorktree({
|
|
||||||
input,
|
|
||||||
createdWorktree,
|
|
||||||
deps,
|
|
||||||
});
|
|
||||||
const workspace = await upsertWorkspaceForWorktree({
|
const workspace = await upsertWorkspaceForWorktree({
|
||||||
inputCwd: input.cwd,
|
inputCwd: input.cwd,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
worktree,
|
worktree: createdWorktree.worktree,
|
||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.github.invalidate({ cwd: worktree.worktreePath });
|
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worktree,
|
worktree: createdWorktree.worktree,
|
||||||
intent: createdWorktree.intent,
|
intent: createdWorktree.intent,
|
||||||
workspace,
|
workspace,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
@@ -87,11 +79,15 @@ export async function createPaseoWorktree(
|
|||||||
export async function attemptFirstAgentBranchAutoName(options: {
|
export async function attemptFirstAgentBranchAutoName(options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
firstAgentContext: FirstAgentContext | undefined;
|
firstAgentContext: FirstAgentContext | undefined;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
generateBranchNameFromContext: (input: {
|
||||||
|
cwd: string;
|
||||||
|
firstAgentContext: FirstAgentContext;
|
||||||
|
}) => Promise<string | null>;
|
||||||
|
getCurrentBranch?: typeof getCurrentBranch;
|
||||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||||
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
|
||||||
const seed = buildAgentBranchNameSeed(options.firstAgentContext);
|
const firstAgentContext = options.firstAgentContext;
|
||||||
if (!seed) {
|
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
|
||||||
return { attempted: false, renamed: false, branchName: null };
|
return { attempted: false, renamed: false, branchName: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +105,27 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
|||||||
return { attempted: false, renamed: false, branchName: null };
|
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);
|
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);
|
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 };
|
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: {
|
async function upsertWorkspaceForWorktree(options: {
|
||||||
inputCwd: string;
|
inputCwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface ResolverHarness {
|
|||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
headRefLookups: GitHubHeadRefLookup[];
|
headRefLookups: GitHubHeadRefLookup[];
|
||||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createResolverHarness(): ResolverHarness {
|
function createResolverHarness(): ResolverHarness {
|
||||||
@@ -51,7 +50,6 @@ function createResolverHarness(): ResolverHarness {
|
|||||||
github,
|
github,
|
||||||
headRefLookups,
|
headRefLookups,
|
||||||
resolveDefaultBranch: async () => "main",
|
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 () => {
|
test("branches off the repo default branch when no explicit fields are set", async () => {
|
||||||
const deps = createResolverHarness();
|
const deps = createResolverHarness();
|
||||||
|
|
||||||
await expect(resolveWorktreeCreationIntent({}, repoRoot, deps)).resolves.toEqual({
|
await expect(
|
||||||
|
resolveWorktreeCreationIntent({ worktreeSlug: "generated-worktree" }, repoRoot, deps),
|
||||||
|
).resolves.toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "generated-worktree",
|
branchName: "generated-worktree",
|
||||||
});
|
});
|
||||||
expect(deps.headRefLookups).toEqual([]);
|
expect(deps.headRefLookups).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -81,7 +81,7 @@ describe("resolveWorktreeCreationIntent", () => {
|
|||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "dev",
|
baseBranch: "dev",
|
||||||
newBranchName: "feature",
|
branchName: "feature",
|
||||||
});
|
});
|
||||||
expect(deps.headRefLookups).toEqual([]);
|
expect(deps.headRefLookups).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,29 @@ import type { WorktreeSource } from "../utils/worktree.js";
|
|||||||
|
|
||||||
export type WorktreeCreationIntent = WorktreeSource;
|
export type WorktreeCreationIntent = WorktreeSource;
|
||||||
|
|
||||||
export interface ResolveWorktreeCreationIntentInput {
|
export type ResolveWorktreeCreationIntentInput =
|
||||||
worktreeSlug?: string;
|
| {
|
||||||
refName?: string;
|
worktreeSlug: string;
|
||||||
action?: "branch-off" | "checkout";
|
refName?: string;
|
||||||
githubPrNumber?: number;
|
action?: "branch-off";
|
||||||
}
|
githubPrNumber?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
worktreeSlug?: string;
|
||||||
|
refName?: string;
|
||||||
|
action: "checkout";
|
||||||
|
githubPrNumber?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
worktreeSlug?: string;
|
||||||
|
refName?: string;
|
||||||
|
action?: undefined;
|
||||||
|
githubPrNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ResolveWorktreeCreationIntentDeps {
|
export interface ResolveWorktreeCreationIntentDeps {
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MissingCheckoutTargetError extends Error {
|
export class MissingCheckoutTargetError extends Error {
|
||||||
@@ -34,7 +46,7 @@ export async function resolveWorktreeCreationIntent(
|
|||||||
return {
|
return {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: input.refName?.trim() || (await resolveDefaultBranch(repoRoot, deps)),
|
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 {
|
return {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: input.refName.trim(),
|
baseBranch: input.refName.trim(),
|
||||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
branchName: input.worktreeSlug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: await resolveDefaultBranch(repoRoot, deps),
|
baseBranch: await resolveDefaultBranch(repoRoot, deps),
|
||||||
newBranchName: deps.generateBranchName(input.worktreeSlug),
|
branchName: input.worktreeSlug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ import {
|
|||||||
type CreatePaseoWorktreeInput,
|
type CreatePaseoWorktreeInput,
|
||||||
type CreatePaseoWorktreeResult,
|
type CreatePaseoWorktreeResult,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
|
||||||
import {
|
import {
|
||||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||||
@@ -3002,7 +3002,7 @@ export class Session {
|
|||||||
if (!resolvedWorkspace) {
|
if (!resolvedWorkspace) {
|
||||||
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
throw new Error(`Workspace not found: ${msg.workspaceId}`);
|
||||||
}
|
}
|
||||||
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
|
this.scheduleAutoNameWorkspaceBranchForFirstAgent({
|
||||||
workspace: resolvedWorkspace,
|
workspace: resolvedWorkspace,
|
||||||
firstAgentContext,
|
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: {
|
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
|
||||||
workspace: PersistedWorkspaceRecord;
|
workspace: PersistedWorkspaceRecord;
|
||||||
firstAgentContext: FirstAgentContext;
|
firstAgentContext: FirstAgentContext;
|
||||||
}): Promise<PersistedWorkspaceRecord> {
|
}): Promise<PersistedWorkspaceRecord> {
|
||||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
|
||||||
const result = await attemptFirstAgentBranchAutoName({
|
const result = await attemptFirstAgentBranchAutoName({
|
||||||
cwd: input.workspace.cwd,
|
cwd: input.workspace.cwd,
|
||||||
firstAgentContext: input.firstAgentContext,
|
firstAgentContext: input.firstAgentContext,
|
||||||
generateBranchName: coreDeps.generateBranchName,
|
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
|
||||||
|
return generateBranchNameFromFirstAgentContext({
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
cwd,
|
||||||
|
firstAgentContext,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!result.renamed || !result.branchName) {
|
if (!result.renamed || !result.branchName) {
|
||||||
return input.workspace;
|
return input.workspace;
|
||||||
@@ -6373,9 +6393,8 @@ export class Session {
|
|||||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
},
|
},
|
||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
const coreDeps = createWorktreeCoreDeps(this.github);
|
|
||||||
const result = await createPaseoWorktree(input, {
|
const result = await createPaseoWorktree(input, {
|
||||||
...coreDeps,
|
github: this.github,
|
||||||
...(options?.resolveDefaultBranch
|
...(options?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -6970,6 +6989,8 @@ export class Session {
|
|||||||
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
||||||
this.createPaseoWorktree(workflowInput, serviceOptions),
|
this.createPaseoWorktree(workflowInput, serviceOptions),
|
||||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||||
|
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||||
|
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
|
||||||
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
|
||||||
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
|
||||||
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async function createBootstrapWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome: options.paseoHome,
|
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?: {
|
function createCoreDeps(options?: { github?: GitHubService }) {
|
||||||
github?: GitHubService;
|
|
||||||
generateBranchName?: (seed: string | undefined) => string;
|
|
||||||
}) {
|
|
||||||
return {
|
return {
|
||||||
github: options?.github ?? createGitHubServiceStub(),
|
github: options?.github ?? createGitHubServiceStub(),
|
||||||
workspaceGitService: {
|
workspaceGitService: {
|
||||||
resolveRepoRoot: async (cwd: string) => cwd,
|
resolveRepoRoot: async (cwd: string) => cwd,
|
||||||
},
|
},
|
||||||
resolveDefaultBranch: async () => "main",
|
resolveDefaultBranch: async () => "main",
|
||||||
generateBranchName: options?.generateBranchName ?? ((seed) => seed ?? "generated-worktree"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +187,33 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
expect(result.intent).toEqual({
|
expect(result.intent).toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "legacy-rpc",
|
branchName: "legacy-rpc",
|
||||||
});
|
});
|
||||||
expect(result.created).toBe(true);
|
expect(result.created).toBe(true);
|
||||||
expect(result.worktree.branchName).toBe("legacy-rpc");
|
expect(result.worktree.branchName).toBe("legacy-rpc");
|
||||||
expect(existsSync(result.worktree.worktreePath)).toBe(true);
|
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 () => {
|
test("checks out an explicit GitHub PR branch with legacy RPC fields", async () => {
|
||||||
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
@@ -259,7 +275,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
expect(result.intent).toEqual({
|
expect(result.intent).toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "mcp-standalone",
|
branchName: "mcp-standalone",
|
||||||
});
|
});
|
||||||
expect(result.worktree.branchName).toBe("mcp-standalone");
|
expect(result.worktree.branchName).toBe("mcp-standalone");
|
||||||
});
|
});
|
||||||
@@ -290,7 +306,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
expect(result.intent).toEqual({
|
expect(result.intent).toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "dev",
|
baseBranch: "dev",
|
||||||
newBranchName: "from-dev",
|
branchName: "from-dev",
|
||||||
});
|
});
|
||||||
expect(mergeBase).toBe(devTip);
|
expect(mergeBase).toBe(devTip);
|
||||||
});
|
});
|
||||||
@@ -500,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
|
|||||||
expect(result.intent).toEqual({
|
expect(result.intent).toEqual({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "agent-worktree",
|
branchName: "agent-worktree",
|
||||||
});
|
});
|
||||||
expect(result.worktree.branchName).toBe("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 type { GitHubService } from "../services/github-service.js";
|
||||||
import {
|
import {
|
||||||
@@ -16,8 +16,12 @@ import {
|
|||||||
import type { FirstAgentContext } from "../shared/messages.js";
|
import type { FirstAgentContext } from "../shared/messages.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
|
||||||
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
|
export interface CreateWorktreeCoreInput {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
worktreeSlug?: string;
|
||||||
|
refName?: string;
|
||||||
|
action?: "branch-off" | "checkout";
|
||||||
|
githubPrNumber?: number;
|
||||||
firstAgentContext?: FirstAgentContext;
|
firstAgentContext?: FirstAgentContext;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
runSetup?: boolean;
|
runSetup?: boolean;
|
||||||
@@ -27,7 +31,6 @@ export interface CreateWorktreeCoreDeps {
|
|||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot" | "resolveDefaultBranch">;
|
||||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||||
generateBranchName: (seed: string | undefined) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWorktreeCoreResult {
|
export interface CreateWorktreeCoreResult {
|
||||||
@@ -42,31 +45,51 @@ export async function createWorktreeCore(
|
|||||||
deps: CreateWorktreeCoreDeps,
|
deps: CreateWorktreeCoreDeps,
|
||||||
): Promise<CreateWorktreeCoreResult> {
|
): Promise<CreateWorktreeCoreResult> {
|
||||||
const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService);
|
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(
|
let intentInput: ResolveWorktreeCreationIntentInput;
|
||||||
{ ...input, worktreeSlug: requestedSlug },
|
if (input.action === "checkout") {
|
||||||
repoRoot,
|
intentInput = {
|
||||||
{
|
action: "checkout",
|
||||||
...deps,
|
refName: input.refName,
|
||||||
resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps),
|
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;
|
let normalizedSlug: string;
|
||||||
|
|
||||||
switch (intent.kind) {
|
switch (intent.kind) {
|
||||||
case "branch-off": {
|
case "branch-off": {
|
||||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.newBranchName));
|
normalizedSlug = intent.branchName;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "checkout-branch": {
|
case "checkout-branch": {
|
||||||
normalizedSlug = validateWorktreeSlug(requestedSlug ?? slugify(intent.branchName));
|
normalizedSlug = requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.branchName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "checkout-github-pr": {
|
case "checkout-github-pr": {
|
||||||
normalizedSlug = validateWorktreeSlug(
|
normalizedSlug =
|
||||||
requestedSlug ?? slugify(intent.localBranchName ?? intent.headRef),
|
requestedWorktreeSlug ?? normalizeWorktreeSlug(intent.localBranchName ?? intent.headRef);
|
||||||
);
|
|
||||||
break;
|
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(
|
async function resolveDefaultBranch(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
deps: CreateWorktreeCoreDeps,
|
deps: CreateWorktreeCoreDeps,
|
||||||
@@ -132,3 +148,7 @@ function validateWorktreeSlug(slug: string): string {
|
|||||||
}
|
}
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorktreeSlug(value: string): string {
|
||||||
|
return validateWorktreeSlug(slugify(value));
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
createPaseoWorktree as createPaseoWorktreeService,
|
createPaseoWorktree as createPaseoWorktreeService,
|
||||||
type CreatePaseoWorktreeFn,
|
type CreatePaseoWorktreeFn,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { createWorktreeCoreDeps } from "./worktree-core.js";
|
|
||||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ function createLegacyWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: options.runSetup ?? true,
|
runSetup: options.runSetup ?? true,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
@@ -262,9 +261,8 @@ function createPaseoWorktreeForTest(options: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (input, serviceOptions) => {
|
return (input, serviceOptions) => {
|
||||||
const coreDeps = createWorktreeCoreDeps(createGitHubServiceStub());
|
|
||||||
return createPaseoWorktreeService(input, {
|
return createPaseoWorktreeService(input, {
|
||||||
...coreDeps,
|
github: createGitHubServiceStub(),
|
||||||
...(serviceOptions?.resolveDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1303,7 +1301,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
intent: {
|
intent: {
|
||||||
kind: "branch-off" as const,
|
kind: "branch-off" as const,
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "fix-attached-pr-context",
|
branchName: "fix-attached-pr-context",
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
|
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
|
||||||
@@ -1445,7 +1443,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
expect(result.intent).toMatchObject({
|
expect(result.intent).toMatchObject({
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: "main",
|
baseBranch: "main",
|
||||||
newBranchName: "resolver-feature",
|
branchName: "resolver-feature",
|
||||||
});
|
});
|
||||||
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
expect(resolveDefaultBranch).toHaveBeenCalledWith(repoDir);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
|
|||||||
},
|
},
|
||||||
) => Promise<CreatePaseoWorktreeResult>;
|
) => Promise<CreatePaseoWorktreeResult>;
|
||||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||||
|
autoNameWorkspaceBranchForFirstAgent?: (input: {
|
||||||
|
workspace: PersistedWorkspaceRecord;
|
||||||
|
firstAgentContext: FirstAgentContext;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentWorktreeSetupContinuationInput {
|
interface AgentWorktreeSetupContinuationInput {
|
||||||
@@ -576,6 +580,12 @@ export async function createPaseoWorktreeWorkflow(
|
|||||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (input.firstAgentContext) {
|
||||||
|
dependencies.autoNameWorkspaceBranchForFirstAgent?.({
|
||||||
|
workspace,
|
||||||
|
firstAgentContext: input.firstAgentContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||||
dependencies.sessionLogger.warn(
|
dependencies.sessionLogger.warn(
|
||||||
{ err: error, workspaceId: workspace.workspaceId },
|
{ err: error, workspaceId: workspace.workspaceId },
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function createLegacyWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: options.runSetup ?? true,
|
runSetup: options.runSetup ?? true,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createLegacyWorktreeForTest(
|
|||||||
source: {
|
source: {
|
||||||
kind: "branch-off",
|
kind: "branch-off",
|
||||||
baseBranch: options.baseBranch,
|
baseBranch: options.baseBranch,
|
||||||
newBranchName: options.branchName,
|
branchName: options.branchName,
|
||||||
},
|
},
|
||||||
runSetup: options.runSetup ?? true,
|
runSetup: options.runSetup ?? true,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
@@ -182,7 +182,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "my-feature",
|
worktreeSlug: "my-feature",
|
||||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
|
||||||
runSetup: true,
|
runSetup: true,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
@@ -796,7 +796,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "seed-uncommitted",
|
worktreeSlug: "seed-uncommitted",
|
||||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/seed" },
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
@@ -824,7 +824,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "preserve-committed",
|
worktreeSlug: "preserve-committed",
|
||||||
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/preserve" },
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
@@ -839,7 +839,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
worktreeSlug: "no-config",
|
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,
|
runSetup: false,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export interface PaseoWorktreeOwnership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WorktreeSource =
|
export type WorktreeSource =
|
||||||
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
|
| { kind: "branch-off"; baseBranch: string; branchName: string }
|
||||||
| { kind: "checkout-branch"; branchName: string }
|
| { kind: "checkout-branch"; branchName: string }
|
||||||
| {
|
| {
|
||||||
kind: "checkout-github-pr";
|
kind: "checkout-github-pr";
|
||||||
@@ -1292,7 +1292,7 @@ async function resolveWorktreeSourcePlan({
|
|||||||
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
|
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
|
||||||
switch (source.kind) {
|
switch (source.kind) {
|
||||||
case "branch-off": {
|
case "branch-off": {
|
||||||
const branchName = source.newBranchName;
|
const branchName = source.branchName;
|
||||||
validateWorktreeBranchName(branchName);
|
validateWorktreeBranchName(branchName);
|
||||||
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
||||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
||||||
|
|||||||
Reference in New Issue
Block a user