diff --git a/docs/data-model.md b/docs/data-model.md index dc65096b66..38c13b63df 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr restore, branch auto-name, and descriptor flows consume those persisted facts rather than rediscovering ownership from a directory that may already be gone. Reconciliation may refresh mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. +An explicit base selection is stored in the checkout's Git-directory Paseo metadata and +mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps +recovery possible after the checkout metadata is deleted. A newly opened workspace inherits +the checkout's selected base; separate worktrees keep separate bases. Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing `worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. @@ -470,7 +474,7 @@ Array of workspace records. A workspace is a specific working directory within a | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | | `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | | `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. | +| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | | `createdAt` | `string` (ISO 8601) | | diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 1ff6059460..96d0d225b5 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -323,7 +323,98 @@ test("All changes combines committed and pending edits and preserves an unfinish }); }); +test("Changes selects exact local and remote bases and retains the selection after reload", async ({ + page, +}, testInfo) => { + const workspace = await createWorkspaceWithCommittedDiff(); + execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); + execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], { + cwd: workspace.repoPath, + }); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString(); + await openWorkspaceChangesSurface(page, workspace); + const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); + const picker = tree.getByTestId("workspace-base-branch-trigger"); + await expect(picker).toBeVisible(); + await picker.click(); + await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("base-picker.png") }); + await page.getByRole("button", { name: "release", exact: true }).click(); + await expect(picker).toContainText("release"); + await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible(); + await picker.click(); + await page.getByRole("button", { name: "upstream/release", exact: true }).click(); + await expect(picker).toContainText("upstream/release"); + await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("base-selected.png") }); + await page.reload(); + await waitForWorkspaceTabsVisible(page); + await expect(picker).toContainText("upstream/release"); + expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe( + head, + ); +}); + +test("Changes shows a failed base selection and lets the user retry", async ({ page }) => { + const workspace = await createWorkspaceWithCommittedDiff(); + execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); + await openWorkspaceChangesSurface(page, workspace); + const picker = page + .getByTestId("workspace-base-branch-trigger") + .filter({ visible: true }) + .first(); + await picker.click(); + const option = page.getByRole("button", { name: "release", exact: true }); + await expect(option).toBeVisible(); + execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath }); + await option.click(); + await expect(page.getByTestId("workspace-base-branch-error")).toContainText( + "Base branch not found", + ); + await expect(picker).toContainText("main"); + execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); + await option.click(); + await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0); + await expect(picker).toContainText("release"); +}); + +test("Changes asks to update a host without the base-branch capability", async ({ page }) => { + let baseRequests = 0; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + if (typeof frame === "string") { + const envelope = JSON.parse(frame) as { message?: { type?: string } }; + if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1; + } + server.send(frame); + }); + server.onMessage((frame) => { + if (typeof frame === "string") { + const envelope = JSON.parse(frame) as { + message?: { payload?: { status?: string; features?: Record } }; + }; + const payload = envelope.message?.payload; + if (payload?.status === "server_info" && payload.features) { + delete payload.features.workspaceBaseBranch; + browser.send(JSON.stringify(envelope)); + return; + } + } + browser.send(frame); + }); + }); + const workspace = await createWorkspaceWithCommittedDiff(); + await openWorkspaceChangesSurface(page, workspace); + await page.getByTestId("workspace-base-branch-trigger").filter({ visible: true }).first().click(); + await expect( + page.getByText("Update the host to change the base branch.", { exact: true }), + ).toBeVisible(); + expect(baseRequests).toBe(0); +}); + test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 4aaa650c70..7a20e98fa2 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({ serverId: string; cwd: string; }): Promise { - const payload = await client.getCheckoutStatus(cwd); + const payload = normalizeCheckoutBaseRef(await client.getCheckoutStatus(cwd)); return payload; } @@ -56,7 +56,7 @@ export function applyCheckoutStatusUpdateFromEvent({ serverId: string; message: CheckoutStatusUpdate; }): void { - const { payload } = message; + const payload = normalizeCheckoutBaseRef(message.payload); const prStatus = payload.prStatus ? normalizeCheckoutPrStatusPayload(payload.prStatus) : undefined; @@ -88,6 +88,13 @@ export function applyCheckoutStatusUpdateFromEvent({ } } +function normalizeCheckoutBaseRef(payload: T): T { + // COMPAT(workspaceBaseBranch): hosts <= v0.11.2 only send the display ref; remove after 2027-10-14. + return !payload.isGit || payload.baseRefExact === undefined + ? payload + : { ...payload, baseRef: payload.baseRefExact }; +} + // requestId changes on every emission and carries no PR state. function prStatusWithoutVolatileFields( prStatus: CheckoutPrStatusPayload, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 08e584b3cc..9ef9780a9a 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -61,6 +61,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button"; import type { GitActions } from "@/git/policy"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; +import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker"; import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -479,6 +480,7 @@ interface ChangesRepositoryToolbarModel { } interface ChangesComparisonToolbarModel { + baseBranchPicker: ReactNode; committedDescription?: string; diffMode: "uncommitted" | "base" | "all"; mode: ChangesToolbarMode; @@ -496,6 +498,7 @@ interface ChangesHeaderProps { } interface BuildChangesHeaderModelInput { + baseBranchPicker: ReactNode; branchName: string | null; committedDescription?: string; compact: boolean; @@ -529,6 +532,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): { workspaceId: input.workspaceId, }, comparison: { + baseBranchPicker: input.baseBranchPicker, committedDescription: input.committedDescription, diffMode: input.diffMode, mode: input.mode, @@ -753,6 +757,7 @@ function ChangesComparisonToolbar({ onSelectBase={model.onSelectBase} onSelectAll={model.onSelectAll} /> + {model.diffMode === "base" ? model.baseBranchPicker : null} {model.selectedDiffStat ? ( buildChangesHeaderModel({ + baseBranchPicker: workspaceId ? ( + + ) : null, branchName: currentBranchName, committedDescription: committedDiffDescription, compact: isMobile, @@ -1921,6 +1934,7 @@ export function ChangesSurface({ workspaceId, }), [ + baseRef, committedDiffDescription, currentBranchName, cwd, diff --git a/packages/app/src/git/workspace-base-branch-picker.tsx b/packages/app/src/git/workspace-base-branch-picker.tsx new file mode 100644 index 0000000000..913b760291 --- /dev/null +++ b/packages/app/src/git/workspace-base-branch-picker.tsx @@ -0,0 +1,187 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useFetchQuery } from "@/data/query"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; +import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Button } from "@/components/ui/button"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { useToast } from "@/contexts/toast-context"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { invalidateCheckoutGitQueriesForClient } from "./query-keys"; + +interface Props { + serverId: string; + workspaceId: string; + cwd: string; + baseRef: string | null; +} + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function refLabel(ref: string): string { + return ref.replace(/^refs\/(heads|remotes)\//, ""); +} + +export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }: Props) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. + const supported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true, + ); + const queryClient = useQueryClient(); + const toast = useToast(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const query = useDebouncedValue(search, 200); + const branches = useFetchQuery({ + queryKey: ["workspaceBaseBranches", serverId, cwd, query], + queryFn: async () => { + if (!client) throw new Error(t("common.errors.daemonClientUnavailable")); + const result = await client.getBranchSuggestions({ + cwd, + refMode: "exact", + query, + limit: 200, + }); + if (result.error) throw new Error(result.error); + return result.branches; + }, + enabled: open && supported && connected && Boolean(client), + retry: false, + dataShape: "value", + staleTimeMs: 0, + }); + const mutation = useMutation({ + mutationFn: async (ref: string) => { + if (!client) throw new Error(t("common.errors.daemonClientUnavailable")); + return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref }); + }, + onSuccess: async () => { + setOpen(false); + toast.show(t("workspace.git.diff.baseBranch.saved")); + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd }); + }, + }); + const options = useMemo( + () => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })), + [branches.data], + ); + const error = mutation.error ?? branches.error; + const title = t("workspace.git.diff.baseBranch.title"); + const label = baseRef ? refLabel(baseRef) : title; + const hint = supported + ? t("workspace.git.diff.baseBranch.hint") + : t("workspace.git.diff.baseBranch.updateHost"); + + const { reset, mutate, isPending } = mutation; + const { refetch, isError } = branches; + const handleOpen = useCallback(() => { + if (!supported) { + toast.error(t("workspace.git.diff.baseBranch.updateHost")); + return; + } + reset(); + setSearch(""); + setOpen(true); + }, [reset, supported, toast, t]); + const handleSelect = useCallback( + (ref: string) => { + if (!isPending) mutate(ref); + }, + [isPending, mutate], + ); + const handleRetry = useCallback(() => { + void refetch(); + }, [refetch]); + const renderOption = useCallback>( + ({ option, selected, active, onPress }) => ( + + ), + [isPending], + ); + const footer = useMemo( + () => ( + + {t("workspace.git.diff.baseBranch.hint")} + {isPending ? : null} + {error ? ( + + {error.message} + + ) : null} + {isError ? ( + + ) : null} + + ), + [t, isPending, error, isError, handleRetry], + ); + + return ( + + + + + + + {hint} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + anchor: { flexShrink: 1, minWidth: 0 }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 098a49ae52..bb093473f4 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -930,6 +930,12 @@ export const ar: TranslationResources = { }, }, diff: { + baseBranch: { + title: "الفرع الأساسي", + hint: "يُستخدم للتغييرات المثبتة والالتزامات وتحديثات Git وطلبات السحب الجديدة. مشترك بين مساحات العمل التي تستخدم نسخة العمل هذه.", + updateHost: "حدّث المضيف لتغيير الفرع الأساسي.", + saved: "تم تحديث الفرع الأساسي", + }, openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ae9e9347e5..7de132eabe 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -938,6 +938,12 @@ export const en = { }, }, diff: { + baseBranch: { + title: "Base branch", + hint: "Used for committed changes, commits, Git updates and new pull requests. Shared by workspaces using this checkout.", + updateHost: "Update the host to change the base branch.", + saved: "Base branch updated", + }, openChangesTab: "Open Changes tab", openDiffTab: "Open Diff tab", closeChangesTab: "Close Changes tab", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3d28cda81f..d6b980b287 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -963,6 +963,12 @@ export const es: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Rama base", + hint: "Se usa para cambios confirmados, commits, actualizaciones de Git y nuevas solicitudes de cambios. Se comparte entre los espacios de trabajo de este checkout.", + updateHost: "Actualiza el host para cambiar la rama base.", + saved: "Rama base actualizada", + }, openChangesTab: "Abrir la pestaña Cambios", openDiffTab: "Abrir la pestaña Diferencia", closeChangesTab: "Cerrar la pestaña Cambios", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index da19c643ad..1984f774b3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -956,6 +956,12 @@ export const fr: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branche de base", + hint: "Utilisée pour les changements validés, les commits, les mises à jour Git et les nouvelles pull requests. Partagée par les espaces de travail de ce checkout.", + updateHost: "Mettez à jour l’hôte pour changer la branche de base.", + saved: "Branche de base mise à jour", + }, openChangesTab: "Ouvrir l’onglet Modifications", openDiffTab: "Ouvrir l’onglet Diff", closeChangesTab: "Fermer l’onglet Modifications", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 73e14a0b68..af4e5927cb 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -943,6 +943,12 @@ export const ja: TranslationResources = { }, }, diff: { + baseBranch: { + title: "ベースブランチ", + hint: "コミット済みの変更、コミット一覧、Git の更新、新規プルリクエストに使用します。同じチェックアウトのワークスペースで共有されます。", + updateHost: "ベースブランチを変更するにはホストを更新してください。", + saved: "ベースブランチを更新しました", + }, openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 117d5d68bc..08dc0cd114 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -937,6 +937,12 @@ export const ko: TranslationResources = { }, }, diff: { + baseBranch: { + title: "기준 브랜치", + hint: "커밋된 변경, 커밋 목록, Git 업데이트 및 새 풀 리퀘스트에 사용됩니다. 같은 체크아웃을 사용하는 워크스페이스가 공유합니다.", + updateHost: "기준 브랜치를 변경하려면 호스트를 업데이트하세요.", + saved: "기준 브랜치가 업데이트되었습니다", + }, openChangesTab: "변경사항 탭 열기", openDiffTab: "Diff 탭 열기", closeChangesTab: "변경사항 탭 닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4460039b7f..56ed3a79c0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -953,6 +953,12 @@ export const ptBR: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branch base", + hint: "Usada para alterações commitadas, commits, atualizações do Git e novos pull requests. Compartilhada pelos espaços de trabalho deste checkout.", + updateHost: "Atualize o host para alterar a branch base.", + saved: "Branch base atualizada", + }, openChangesTab: "Abrir a aba Alterações", openDiffTab: "Abrir a aba Diff", closeChangesTab: "Fechar a aba Alterações", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 012e6eb47b..2bfd0e5c44 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -947,6 +947,12 @@ export const ru: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Базовая ветка", + hint: "Используется для закоммиченных изменений, коммитов, обновлений Git и новых pull request. Общая для рабочих пространств с этой рабочей копией.", + updateHost: "Обновите хост, чтобы изменить базовую ветку.", + saved: "Базовая ветка обновлена", + }, openChangesTab: "Открыть вкладку «Изменения»", openDiffTab: "Открыть вкладку «Дифф»", closeChangesTab: "Закрыть вкладку «Изменения»", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1c6106553e..9e7107857a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -922,6 +922,12 @@ export const zhCN: TranslationResources = { }, }, diff: { + baseBranch: { + title: "基准分支", + hint: "用于已提交改动、提交列表、Git 更新和新建 PR。同一 checkout 的工作区共享此设置。", + updateHost: "更新 Host 后即可修改基准分支。", + saved: "基准分支已更新", + }, openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index bfc97f5674..bfa49f2352 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3546,6 +3546,22 @@ export class DaemonClient { return { pinnedAt: payload.pinnedAt }; } + async setWorkspaceBaseBranch(options: { + workspaceId: string; + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }> { + const { requestId, workspaceId, baseRef } = options; + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.base_branch.set.response">({ + requestId, + message: { type: "workspace.base_branch.set.request", workspaceId, baseRef }, + }); + if (!payload.accepted) + throw new Error(payload.error ?? "Changing the workspace base branch failed"); + return { baseRef: payload.baseRef }; + } + async inspectWorkspaceRecovery( workspaceId: string, requestId?: string, @@ -5098,7 +5114,7 @@ export class DaemonClient { } async getBranchSuggestions( - options: { cwd: string; query?: string; limit?: number }, + options: { cwd: string; query?: string; limit?: number; refMode?: "exact" }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -5108,6 +5124,7 @@ export class DaemonClient { cwd: options.cwd, query: options.query, limit: options.limit, + refMode: options.refMode, }, responseType: "branch_suggestions_response", }); diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 1b11a3032f..f00801e1c7 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -607,6 +607,29 @@ test("workspace handles keep identity and refresh snapshots through existing dri }), ); await expect(titlePromise).resolves.toEqual({ title: "SDK review" }); + const basePromise = workspace.setBaseBranch({ + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "workspace.base_branch.set.request", + workspaceId: "workspace_sdk", + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + ws.message( + sessionMessage({ + type: "workspace.base_branch.set.response", + payload: { + requestId: "workspace-base-request", + workspaceId: "workspace_sdk", + accepted: true, + baseRef: "refs/heads/release", + error: null, + }, + }), + ); + await expect(basePromise).resolves.toEqual({ baseRef: "refs/heads/release" }); unsubscribe(); ws.message( diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3655b0023a..06c23527cd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -189,6 +189,10 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + setBaseBranch(options: { + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }>; archive(requestId?: string): Promise; /** * Subscribes to already-emitted daemon workspace_update events for this id. @@ -874,6 +878,8 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setBaseBranch: (options) => + daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { const result = await daemonClient.archiveWorkspace(id, requestId); if (current) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b5d368928e..f0d6c21871 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1037,6 +1037,13 @@ export const WorkspacePinSetRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceBaseBranchSetRequestSchema = z.object({ + type: z.literal("workspace.base_branch.set.request"), + workspaceId: z.string(), + baseRef: z.string(), + requestId: z.string(), +}); + export const WorkspaceLabelColorSchema = z.enum(WORKSPACE_LABEL_COLORS); export const WorkspaceLabelDefinitionSchema = z.object({ name: z.string(), @@ -2193,6 +2200,17 @@ export const WorkspacePinSetResponseSchema = z.object({ payload: WorkspacePinSetResponsePayloadSchema, }); +export const WorkspaceBaseBranchSetResponseSchema = z.object({ + type: z.literal("workspace.base_branch.set.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + baseRef: z.string().nullable(), + error: z.string().nullable(), + }), +}); + export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("recoverable"), @@ -2510,6 +2528,7 @@ export const BranchSuggestionsRequestSchema = z.object({ cwd: z.string(), query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), + refMode: z.literal("exact").optional(), requestId: z.string(), }); @@ -3324,6 +3343,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, WorkspacePinSetRequestSchema, + WorkspaceBaseBranchSetRequestSchema, WorkspaceLabelListRequestSchema, WorkspaceLabelAssignmentSetRequestSchema, WorkspaceLabelUpdateRequestSchema, @@ -3706,6 +3726,8 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. + workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), // COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05. @@ -5399,6 +5421,7 @@ const CheckoutStatusCommonSchema = z.object({ cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), + baseRefExact: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7197,6 +7220,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveResponseSchema, WorkspaceTitleSetResponseSchema, WorkspacePinSetResponseSchema, + WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, WaitForFinishResponseMessageSchema, @@ -7558,6 +7582,7 @@ export type ProjectIconSetRequest = z.infer; export type ProjectRemoveRequest = z.infer; export type WorkspaceTitleSetRequest = z.infer; export type WorkspacePinSetRequest = z.infer; +export type WorkspaceBaseBranchSetRequest = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index abc89b06aa..a15c1ae1be 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -12,8 +12,65 @@ import { WorkspaceSetupSnapshotSchema, WorkspaceSetupProgressMessageSchema, AgentTimelineEntryPayloadSchema, + CheckoutStatusResponseSchema, + BranchSuggestionsRequestSchema, + WorkspaceBaseBranchSetRequestSchema, + WorkspaceBaseBranchSetResponseSchema, } from "./messages.js"; +test("base selection RPCs are correlated while older checkout and branch messages remain valid", () => { + const payload = { + cwd: "/repo", + requestId: "status", + error: null, + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: "/repo", + currentBranch: "feature", + isDirty: false, + baseRef: "release", + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + }; + expect( + CheckoutStatusResponseSchema.safeParse({ type: "checkout_status_response", payload }).success, + ).toBe(true); + expect( + CheckoutStatusResponseSchema.parse({ + type: "checkout_status_response", + payload: { ...payload, baseRefExact: "refs/remotes/upstream/release" }, + }).payload.baseRefExact, + ).toBe("refs/remotes/upstream/release"); + expect( + BranchSuggestionsRequestSchema.safeParse({ + type: "branch_suggestions_request", + cwd: "/repo", + requestId: "branches", + }).success, + ).toBe(true); + const request = { + type: "workspace.base_branch.set.request", + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + }; + expect(WorkspaceBaseBranchSetRequestSchema.parse(request)).toEqual(request); + const response = { + type: "workspace.base_branch.set.response", + payload: { + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + accepted: true, + error: null, + }, + }; + expect(WorkspaceBaseBranchSetResponseSchema.parse(response)).toEqual(response); +}); + test("terminal listings accept older rows and retain new per-terminal directories", () => { const response = { type: "list_terminals_response", diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 0719e3d984..8cc9d6d1a0 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -216,6 +216,7 @@ const INBOUND_PERMISSION = { "workspace.label.list.request": "workspace.read", "workspace.label.update.request": "workspace.manage", "workspace.pin.set.request": "workspace.manage", + "workspace.base_branch.set.request": "workspace.write", "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", @@ -458,6 +459,7 @@ const OUTBOUND_PERMISSION = { "workspace.label.update": "workspace.read", "workspace.label.update.response": "workspace.manage", "workspace.pin.set.response": "workspace.manage", + "workspace.base_branch.set.response": "workspace.write", "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 917813d587..8293ce965f 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -195,6 +195,7 @@ export class CheckoutDiffManager { remoteUrl: snapshot.git.remoteUrl, isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind, aheadOfOrigin: snapshot.git.aheadOfOrigin, behindOfOrigin: snapshot.git.behindOfOrigin, diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 7f7365efca..90c900435c 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -61,6 +61,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -81,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts index 8c7e491970..4456573624 100644 --- a/packages/server/src/server/git-metadata-event-rules.test.ts +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -21,6 +21,7 @@ describe("Git metadata event rules", () => { ["worktree", "HEAD", { kind: "owner", refreshBase: true }], ["worktree", "config.worktree", { kind: "owner", refreshBase: true }], ["worktree", "paseo/worktree.json", { kind: "owner", refreshBase: true }], + ["common", "paseo/worktree.json", { kind: "owner", refreshBase: true }], ["worktree", "COMMIT_EDITMSG", { kind: "ignore" }], ["worktree", "ORIG_HEAD", { kind: "ignore" }], ["common", "index", { kind: "owner", refreshBase: false }], diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts index dab6bc4887..ff82747ba0 100644 --- a/packages/server/src/server/git-metadata-event-rules.ts +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -44,7 +44,7 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "paseo-worktree-metadata", - scope: "worktree", + scope: "both", match: "exact", path: "paseo/worktree.json", route: "owner", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8911f9acf1..0292aea739 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,6 +3,12 @@ import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; +import type { WorkspaceBaseBranchSetRequest } from "@getpaseo/protocol/messages"; + import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -476,6 +482,7 @@ export interface SessionOptions { creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceBaseBranch?: WorkspaceBaseBranchService; directorySync?: DirectorySyncService; workspaceLabelService?: WorkspaceLabelService; filesystem?: SessionFileSystem; @@ -772,6 +779,7 @@ export class Session { private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceRecovery: WorkspaceRecoveryService; + private workspaceBaseBranch: WorkspaceBaseBranchService | undefined; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; private readonly pluginRuntime: SessionOptions["pluginRuntime"]; @@ -955,6 +963,8 @@ export class Session { this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; this.workspaceRuntimeStore = workspaceRuntimeStore; + this.workspaceBaseBranch = options.workspaceBaseBranch; + this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -3195,6 +3205,8 @@ export class Session { return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); + case "workspace.base_branch.set.request": + return this.handleWorkspaceBaseBranchSetRequest(msg); default: return undefined; } @@ -4058,6 +4070,39 @@ export class Session { } } + private async handleWorkspaceBaseBranchSetRequest( + msg: WorkspaceBaseBranchSetRequest, + ): Promise { + try { + this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({ + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + const baseRef = await this.workspaceBaseBranch.setBaseBranch(msg); + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: true, + baseRef, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: false, + baseRef: null, + error: getErrorMessage(error), + }, + }); + } + } + private async handleWorkspacePinSetRequest( workspaceId: string, pinned: boolean, diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index ceb69e9623..4deb28a56b 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -379,13 +379,14 @@ export class CheckoutSession { } async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { - const { cwd, query, limit, requestId } = msg; + const { cwd, query, limit, refMode, requestId } = msg; try { const resolvedCwd = expandTilde(cwd); const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { query, limit, + refMode, }); this.host.emit({ type: "branch_suggestions_response", diff --git a/packages/server/src/server/session/workspace-base-branch/service.ts b/packages/server/src/server/session/workspace-base-branch/service.ts new file mode 100644 index 0000000000..9685849d1d --- /dev/null +++ b/packages/server/src/server/session/workspace-base-branch/service.ts @@ -0,0 +1,84 @@ +import { unlinkSync } from "node:fs"; +import { resolve } from "node:path"; +import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadataFile, +} from "../../../utils/worktree-metadata.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; + +interface Dependencies { + workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; +} + +export function createWorkspaceBaseBranchService(deps: Dependencies) { + const queues = new Map>(); + + async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise { + const workspace = await deps.workspaceRegistry.get(input.workspaceId); + if (!workspace || workspace.archivedAt) throw new Error("Workspace is unavailable"); + const facts = await getCheckoutSnapshotFacts(workspace.cwd); + if (!facts.isGit) throw new Error("Workspace is not a Git checkout"); + const root = resolve(facts.worktreeRoot); + const previous = queues.get(root); + let release!: () => void; + const queued = new Promise((done) => { + release = done; + }); + queues.set(root, queued); + await previous; + + try { + const current = await deps.workspaceRegistry.get(input.workspaceId); + if (!current || current.archivedAt) throw new Error("Workspace is unavailable"); + const records = await deps.workspaceRegistry.list(); + const matching = records.filter( + (record) => + record.workspaceId === input.workspaceId || + resolve(record.worktreeRoot ?? record.cwd) === root, + ); + await deps.workspaceGitService.getSnapshot(root); + const metadata = readPaseoWorktreeMetadata(root); + const baseRef = await setCheckoutBaseBranch({ cwd: root, baseRef: input.baseRef }); + const updatedAt = new Date().toISOString(); + try { + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: baseRef, + updatedAt, + })); + } + } catch (error) { + if (metadata) writePaseoWorktreeMetadataFile(root, metadata); + else unlinkSync(getPaseoWorktreeMetadataPath(root)); + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: record.baseBranch, + updatedAt: record.updatedAt, + })); + } + throw error; + } + for (const cwd of new Set(matching.map((record) => record.cwd))) { + await deps.workspaceGitService.getSnapshot(cwd, { + force: true, + includeForge: false, + reason: "base-branch-change", + }); + } + return baseRef; + } finally { + release(); + if (queues.get(root) === queued) queues.delete(root); + } + } + + return { setBaseBranch }; +} + +export type WorkspaceBaseBranchService = ReturnType; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 08d7b93bf4..d5c5cd88d8 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -20,6 +20,7 @@ import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.j import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js"; +import { readPaseoWorktreeMetadata } from "../../../utils/worktree-metadata.js"; export interface ResolveOrCreateWorkspaceIdInput { createdWorktree: CreatePaseoWorktreeWorkflowResult | null; @@ -248,6 +249,9 @@ export function createWorkspaceProvisioningService(deps: { ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); + const baseMetadata = checkout.isGit + ? readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd) + : null; const project = projectId ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. @@ -257,6 +261,7 @@ export function createWorkspaceProvisioningService(deps: { workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), + baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null, title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a78f88bfc0..0152bd4c19 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -16,6 +16,10 @@ import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; import { ConnectedHosts } from "./connected-hosts/index.js"; import { stat } from "node:fs/promises"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import { CreationService } from "./creation/index.js"; import { MessageReceipts } from "./message-receipts/index.js"; @@ -568,6 +572,7 @@ export class VoiceAssistantWebSocketServer { private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceBaseBranch: WorkspaceBaseBranchService; private readonly workspaceLabelService: WorkspaceLabelService | null; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; @@ -755,6 +760,10 @@ export class VoiceAssistantWebSocketServer { this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); + this.workspaceBaseBranch = createWorkspaceBaseBranchService({ + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; @@ -1660,6 +1669,7 @@ export class VoiceAssistantWebSocketServer { creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceBaseBranch: this.workspaceBaseBranch, workspaceLabelService: this.workspaceLabelService ?? undefined, directorySync: this.directorySync, scheduleService: this.scheduleService, @@ -2013,6 +2023,7 @@ export class VoiceAssistantWebSocketServer { creationLifecycle: true, hubAgentRpc: true, backgroundWorkspaces: true, + workspaceBaseBranch: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index df229f9221..dd5a6295d6 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { createWorktree } from "../utils/worktree.js"; +import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { createDaemonTestContext, DaemonClient, @@ -277,6 +278,109 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test("changing a checkout base updates subscribed clients and shared workspace recovery records", async () => { + const repo = createGitRepo(); + execFileSync("git", ["checkout", "-b", "release"], { cwd: repo }); + writeFileSync(path.join(repo, "release.txt"), "release\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo }); + writeFileSync(path.join(repo, "feature.txt"), "feature\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], { cwd: repo }); + const first = await createLocalWorkspace(repo, "First"); + const second = await createLocalWorkspace(repo, "Second"); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + reconnect: { enabled: false }, + }); + await observer.connect(); + const refs: string[] = []; + const stop = observer.on("checkout_status_update", (message) => { + if (message.payload.cwd === repo && message.payload.baseRefExact) + refs.push(message.payload.baseRefExact); + }); + const events = observer.observeEvents(["checkout_status_update"]); + try { + await events.ready; + await observer.fetchWorkspaces({ subscribe: {} }); + expect(ctx.client.getLastServerInfoMessage()?.features?.workspaceBaseBranch).toBe(true); + await ctx.client.getCheckoutStatus(repo); + expect( + (await ctx.client.getCheckoutDiff(repo, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/release" }); + await expect.poll(() => refs).toContain("refs/heads/release"); + const diff = await observer.getCheckoutDiff(repo, { mode: "base" }); + expect(diff.files.map((file) => file.path)).not.toContain("release.txt"); + expect(diff.files.map((file) => file.path)).toContain("feature.txt"); + expect((await observer.listCheckoutCommits(repo)).baseRef).toBe("refs/heads/release"); + const records = JSON.parse( + readFileSync(path.join(ctx.daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ); + expect(records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ workspaceId: first, baseBranch: "refs/heads/release" }), + expect.objectContaining({ workspaceId: second, baseBranch: "refs/heads/release" }), + ]), + ); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/missing" }), + ).rejects.toThrow("Base branch not found"); + expect((await observer.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release"); + } finally { + stop(); + await events.release(); + await observer.close(); + } +}); + +test("a selected checkout base survives a daemon restart", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const id = await createLocalWorkspace(repo, "Restart"); + await ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/release" }); + const previous = ctx; + const paseoHomeRoot = path.dirname(previous.daemon.paseoHome); + await previous.client.close(); + await previous.daemon.daemon.stop(); + tempRoots.push(previous.daemon.staticDir); + ctx = await createDaemonTestContext({ paseoHomeRoot }); + expect( + (await ctx.client.fetchWorkspaces()).entries.some((workspace) => workspace.id === id), + ).toBe(true); + expect((await ctx.client.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release"); +}); + +test("a managed workspace restores its changed base after archival removes the checkout", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "changed-base", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + const joinedId = await createLocalWorkspace( + workspace.workspaceDirectory, + "Joined after changing base", + ); + expect((await ctx.client.archiveWorkspace(workspace.id)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); + expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(false); + await ctx.client.restoreWorkspace(joinedId); + expect(readPaseoWorktreeMetadata(workspace.workspaceDirectory)?.baseRef).toBe( + "refs/heads/release", + ); + expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe( + "refs/heads/release", + ); +}); + test.each(["worktree", "workspace"] as const)( "%s archive immediately removes the deleted worktree from a warm CLI listing", async (command) => { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 295d579265..6d6b0f61b6 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -160,6 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot { isPaseoOwnedWorktree: boolean; isDirty: boolean | null; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -296,6 +297,7 @@ export type WorkspaceGitReadOptions = export interface WorkspaceGitBranchSuggestionsOptions { query?: string; limit?: number; + refMode?: "exact"; } export interface WorkspaceGitStashListOptions { @@ -905,7 +907,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; - const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + const key = JSON.stringify([ + "branch-suggestions", + normalizedCwd, + query, + limit ?? null, + options?.refMode, + ]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => this.deps.listBranchSuggestions(normalizedCwd, options), ); @@ -3521,6 +3529,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = target.cwd; const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target); + const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null; const baseContext: CheckoutContext = { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, @@ -3528,6 +3537,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { runGitCommand: runRefreshGitCommand, }; const facts = await this.loadCheckoutFacts(target, baseContext); + if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) { + this.invalidateCheckoutDiffCache(cwd, "base"); + this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base"); + } const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { @@ -3560,6 +3573,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ? checkoutStatus.isDirty : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, + baseRefExact: checkoutStatus.baseRefExact, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 181ec96eae..3170281d60 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -108,7 +108,7 @@ export function initialWorkspacePlacement( /** * Applies live placement facts without rewriting the workspace's durable name - * or its creation-time base branch. + * or its selected base branch. * * A null checkout is an absence, not an observation: nothing was read at the * workspace directory, so the recorded placement stands. Only a directory that diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 61fcb4f720..d42da74da7 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -75,9 +75,9 @@ const PersistedWorkspaceRecordSchema = z.object({ // archive and recovery do not need the directory to still exist in order to // recover placement. worktreeRoot: z.string().nullable().default(null), - // Comparison base preserved across worktree deletion and restore. New branch-off + // Current base preserved across worktree deletion and restore. New branch-off // workspaces store the resolved ref (e.g. refs/remotes/upstream/main); legacy and - // PR checkout records may contain a bare branch name. Ordinary checkouts use null. + // PR checkout records may contain a bare branch name. Null means no recorded base. baseBranch: z .string() .nullable() diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index bd52b0e49f..eb45d69c42 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -32,6 +32,8 @@ import { getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, + listCheckoutCommits, + setCheckoutBaseBranch, mergeToBase, mergeFromBase, MergeConflictError, @@ -263,6 +265,136 @@ describe("checkout git utilities", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("changing the checkout base persists an exact ref without changing HEAD or dirty files", async () => { + execFileSync("git", ["checkout", "-b", "release"], { cwd: repoDir }); + commitFile(repoDir, "release.txt", "release\n", "release change"); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature change"); + execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "dirty\n"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString(); + + expect(await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" })).toBe( + "refs/heads/release", + ); + const localDiff = await getCheckoutDiff(repoDir, { mode: "base" }); + expect(localDiff.diff).toContain("feature.txt"); + expect(localDiff.diff).not.toContain("release.txt"); + expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe("refs/heads/release"); + expect(await getCheckoutSnapshotFacts(repoDir)).toMatchObject({ + storedBaseRef: "refs/heads/release", + }); + + await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/remotes/upstream/release" }); + expect((await getCheckoutDiff(repoDir, { mode: "base" })).diff).toContain("release.txt"); + expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe( + "refs/remotes/upstream/release", + ); + await expect( + getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/heads/release" }), + ).rejects.toThrow("Base ref mismatch"); + expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString()).toBe(head); + expect(readTextFile(join(repoDir, "file.txt"))).toBe("dirty\n"); + expect(await getCurrentBranch(repoDir)).toBe("feature"); + }); + + it("rejects non-branch refs and missing bases without changing the saved base", async () => { + await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" }); + const metadata = readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8"); + execFileSync("git", ["tag", "release-tag"], { cwd: repoDir }); + for (const baseRef of [ + "missing", + "HEAD", + "refs/tags/release-tag", + "main~1", + "--help", + "refs/heads/main^{commit}", + ]) { + await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef })).rejects.toThrow(); + expect(readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8")).toBe(metadata); + } + }); + + it("changing a managed worktree base preserves its runtime and PR identity", async () => { + const worktree = await createLegacyWorktreeForTest({ + cwd: repoDir, + branchName: "feature/base", + baseBranch: "main", + worktreeSlug: "base", + paseoHome, + runSetup: false, + }); + const original = readPaseoWorktreeMetadata(worktree.worktreePath); + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + await setCheckoutBaseBranch({ cwd: worktree.worktreePath, baseRef: "release" }); + expect(readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({ + ...original, + baseRefName: "release", + baseRef: "refs/heads/release", + }); + expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({ + storedBaseRef: "refs/heads/release", + }); + }); + + it("lists exact local and all remote refs separately for the base picker", async () => { + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir }); + const suggestions = await listBranchSuggestions(repoDir, { + refMode: "exact", + query: "release", + }); + expect(suggestions.map((entry) => entry.name).sort()).toEqual([ + "refs/heads/release", + "refs/remotes/origin/release", + "refs/remotes/upstream/release", + ]); + expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({ + hasLocal: true, + hasRemote: false, + }); + }); + + it("merges from and into the selected base without changing the repository default branch", async () => { + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature"); + execFileSync("git", ["checkout", "release"], { cwd: repoDir }); + commitFile(repoDir, "release.txt", "release\n", "release"); + execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); + await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "release" }); + await mergeFromBase(repoDir); + expect(readTextFile(join(repoDir, "release.txt"))).toBe("release\n"); + const featureHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString(); + const mainHead = execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString(); + await mergeToBase(repoDir); + expect(execFileSync("git", ["rev-parse", "release"], { cwd: repoDir }).toString()).toBe( + featureHead, + ); + expect(execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString()).toBe(mainHead); + await expect(mergeFromBase(repoDir, { baseRef: "main" })).rejects.toThrow("Base ref mismatch"); + }); + + it("creates new pull requests targeting the selected base", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + commitFile(repoDir, "feature.txt", "feature\n", "feature"); + await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" }); + const targets: string[] = []; + const adapter = createGitHubServiceForStatus(null); + adapter.createPullRequest = async (input) => { + targets.push(input.base); + return { url: "https://github.com/acme/repo/pull/1", number: 1 }; + }; + await createPullRequest(repoDir, { title: "Feature", body: "Feature" }, adapter); + expect(targets).toEqual(["release"]); + await expect( + createPullRequest(repoDir, { title: "Stale target", body: "", base: "main" }, adapter), + ).rejects.toThrow("Base ref mismatch"); + }); + it("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); mkdirSync(nonGitDir, { recursive: true }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index a0cbaa7afc..eda96de62c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -51,6 +51,7 @@ import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata, rebindPaseoWorktreeChangeRequestHint, + setPaseoWorktreeBaseRef, } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", @@ -323,15 +324,30 @@ function sortBranchSuggestions( export async function listBranchSuggestions( cwd: string, - options?: { query?: string; limit?: number }, + options: { query?: string; limit?: number; refMode?: "exact" } = {}, ): Promise { await requireGitRepo(cwd); - const requestedLimit = options?.limit ?? 50; + const requestedLimit = options.limit ?? 50; const limit = Math.max(1, Math.min(200, requestedLimit)); - const rawQuery = options?.query?.trim().toLowerCase() ?? ""; + const rawQuery = options.query?.trim().toLowerCase() ?? ""; const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery; + if (options.refMode === "exact") { + const refs = await listGitRefs(cwd, "refs"); + const branches = refs.filter((ref) => { + const isBranch = ref.name.startsWith("refs/heads/") || ref.name.startsWith("refs/remotes/"); + return isBranch && !ref.name.endsWith("/HEAD") && ref.name.toLowerCase().includes(rawQuery); + }); + branches.sort((a, b) => b.committerDate - a.committerDate || a.name.localeCompare(b.name)); + return branches.slice(0, limit).map((ref) => ({ + name: ref.name, + committerDate: ref.committerDate, + hasLocal: ref.name.startsWith("refs/heads/"), + hasRemote: ref.name.startsWith("refs/remotes/"), + })); + } + const [localRefs, remoteRefs] = await Promise.all([ listGitRefs(cwd, "refs/heads"), listGitRefs(cwd, "refs/remotes/origin"), @@ -889,6 +905,7 @@ export interface CheckoutStatusGitNonPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -908,6 +925,7 @@ export interface CheckoutStatusGitPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1308,12 +1326,33 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context }); - if (!paseoWorktree.isPaseoOwnedWorktree) { - return null; - } + const worktreeRoot = await getWorktreeRoot(cwd, context); + return worktreeRoot ? readPaseoWorktreeBaseRef(worktreeRoot) : null; +} - return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot); +export async function setCheckoutBaseBranch(input: { + cwd: string; + baseRef: string; +}): Promise { + await requireGitRepo(input.cwd); + const requested = input.baseRef.trim(); + const qualified = requested.startsWith("refs/"); + const candidates = qualified + ? [requested] + : [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`]; + for (const candidate of candidates) { + if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue; + const valid = await runGitCommand(["check-ref-format", candidate], { + cwd: input.cwd, + acceptExitCodes: [0, 1], + }); + if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue; + const worktreeRoot = await getWorktreeRoot(input.cwd); + if (!worktreeRoot) throw new NotGitRepoError(input.cwd); + setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate }); + return candidate; + } + throw new Error(`Base branch not found: ${requested}`); } async function getResolvedBaseRefForCwd( @@ -1786,7 +1825,11 @@ async function getAheadBehind( context?: CheckoutContext, ): Promise { const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + !currentBranch || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } const comparisonBaseRef = @@ -2128,7 +2171,8 @@ export async function getCheckoutSnapshotFacts( if ( resolvedBaseRef && inspected.currentBranch && - branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch + (isQualifiedBranchRef(resolvedBaseRef) || + branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch) ) { comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch( () => null, @@ -2373,6 +2417,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef ?? baseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2391,6 +2436,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2605,7 +2651,10 @@ async function tryResolveCheckoutCommitsBaseRef( return null; } const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } try { diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index fcb0454672..067a5ff608 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({ localBranchName: z.string().min(1).optional(), }); -// baseRefName is the display name; baseRef is the exact ref the worktree was cut from +// baseRefName is the display name; baseRef is the checkout's current exact base // ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it // existed only have the name — there are no migrations, so readers fall back. const PaseoWorktreeMetadataV1Schema = z.object({ @@ -249,6 +249,18 @@ export async function writePaseoWorktreeMetadata( }); } +export async function setPaseoWorktreeBaseRef(input: { worktreeRoot: string; baseRef: string }): Promise { + return mutateMetadata(input.worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if (!current) throw new Error("Cannot change base: missing worktree metadata"); + await writePaseoWorktreeMetadataFile(root, { + ...current, + baseRefName: branchNameFromRef(input.baseRef), + baseRef: input.baseRef, + }); + }); +} + export async function writePaseoWorktreeRuntimeMetadata( worktreeRoot: string, options: { worktreePort: number },