From 3ed57e9dd72df4927b08f16859eaf8d4d2d70ac2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 22:30:14 +0700 Subject: [PATCH] Change a worktree workspace base from Changes (#8642) Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it. Supersedes #6872. Co-authored-by: Tianyu Yang --- docs/data-model.md | 6 +- packages/app/e2e/browser/changes-pane.spec.ts | 126 ++++++++++++ packages/app/src/git/checkout-status-cache.ts | 11 +- packages/app/src/git/diff-pane.tsx | 29 +++ packages/app/src/git/policy.test.ts | 84 +++++++- packages/app/src/git/policy.ts | 28 ++- packages/app/src/git/use-actions.tsx | 15 +- .../src/git/workspace-base-branch-picker.tsx | 179 ++++++++++++++++++ packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/client/src/daemon-client.ts | 19 +- packages/client/src/index.test.ts | 23 +++ packages/client/src/index.ts | 6 + packages/protocol/src/messages.ts | 25 +++ .../protocol/src/messages.wire-compat.test.ts | 57 ++++++ .../authorization/operation-permissions.ts | 2 + .../src/server/checkout-diff-manager.ts | 1 + .../src/server/checkout/status-projection.ts | 2 + .../server/git-metadata-event-rules.test.ts | 1 + .../src/server/git-metadata-event-rules.ts | 2 +- packages/server/src/server/session.ts | 55 ++++++ .../session/checkout/checkout-session.ts | 3 +- .../session/workspace-base-branch/service.ts | 104 ++++++++++ .../workspace-provisioning-service.test.ts | 61 ++++++ .../workspace-provisioning-service.ts | 42 ++-- .../server/src/server/websocket-server.ts | 15 +- ...orkspace-archive-record-scoped.e2e.test.ts | 113 +++++++++++ .../src/server/workspace-git-service.test.ts | 29 +++ .../src/server/workspace-git-service.ts | 16 +- .../src/server/workspace-registry-model.ts | 2 +- .../server/src/server/workspace-registry.ts | 4 +- .../server/src/utils/checkout-git.test.ts | 58 ++++++ packages/server/src/utils/checkout-git.ts | 76 ++++++-- .../server/src/utils/worktree-metadata.ts | 17 +- 41 files changed, 1192 insertions(+), 46 deletions(-) create mode 100644 packages/app/src/git/workspace-base-branch-picker.tsx create mode 100644 packages/server/src/server/session/workspace-base-branch/service.ts 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 aa884c66e9..a233567b5f 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -323,6 +323,110 @@ 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 createWorktreeWithCommittedDiff(); + 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 lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => { + const workspace = await createWorktreeWithCommittedDiff(); + execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); + await openCompactChanges(page, workspace); + const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true }); + await picker.click(); + await expect(page.getByText("Change base", { exact: true })).toBeVisible(); + const release = page.getByRole("button", { name: "release", exact: true }); + await expect(release).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") }); + await release.click(); + await expect(picker).toContainText("release"); + await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") }); +}); + +test("Changes shows a failed base selection and lets the user retry", async ({ page }) => { + const workspace = await createWorktreeWithCommittedDiff(); + 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 shows the base as text on 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 createWorktreeWithCommittedDiff(); + await openWorkspaceChangesSurface(page, workspace); + await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0); + expect(baseRequests).toBe(0); +}); + test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); @@ -1657,6 +1761,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise { + const repo = await createTempGitRepo("changes-base-worktree-", { + files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }], + }); + const client = await connectSeedClient(); + cleanupTasks.push({ + run: async () => { + await client.close(); + await repo.cleanup(); + }, + }); + const created = await client.createWorkspace({ + source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" }, + }); + if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree"); + const cwd = created.workspace.workspaceDirectory; + await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n"); + execFileSync("git", ["add", "committed-only.ts"], { cwd }); + execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd }); + return { id: created.workspace.id, repoPath: cwd }; +} + async function createWorkspaceWithCommittedDiff(): Promise { const repo = await createTempGitRepo("changes-committed-", { files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }], 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..d4a7d43ea8 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -19,6 +19,7 @@ import type { Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, + ArrowRight, ChevronDown, Columns2, ExternalLink, @@ -61,6 +62,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"; @@ -201,6 +203,7 @@ const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedAlignJustify = withUnistyles(AlignJustify); +const ThemedArrowRight = withUnistyles(ArrowRight); const ThemedColumns2 = withUnistyles(Columns2); const ThemedPilcrow = withUnistyles(Pilcrow); const ThemedWrapText = withUnistyles(WrapText); @@ -470,6 +473,8 @@ interface ChangesPullRequestLinkModel extends Pick{children}; } +function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean { + return status?.isPaseoOwnedWorktree === true; +} + function ChangesRepositoryToolbar({ compact, model, @@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({ isGitCheckout testID="changes-branch-switcher" /> + {model.baseRef ? ( + <> + + + + ) : null} {model.pullRequest ? ( @@ -1904,6 +1929,8 @@ export function ChangesSurface({ const changesHeaderModel = useMemo( () => buildChangesHeaderModel({ + baseRef: baseRef ?? null, + isWorktree: isManagedWorktree(status), branchName: currentBranchName, committedDescription: committedDiffDescription, compact: isMobile, @@ -1921,6 +1948,8 @@ export function ChangesSurface({ workspaceId, }), [ + baseRef, + status, committedDiffDescription, currentBranchName, cwd, diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index efbd61efa6..34f6e93d1d 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages"; import { i18n } from "@/i18n/i18next"; -import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy"; +import { + actionsWithoutPrimary, + buildGitActions, + isCurrentBranchBase, + canMergeIntoLocalBase, + type BuildGitActionsInput, +} from "./policy"; import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability"; type GithubMergeFactsFixture = ForgeSpecificStatusFacts & { @@ -72,6 +78,7 @@ function createInput( hasRemote: false, isPaseoOwnedWorktree: false, isOnBaseBranch: true, + canMergeLocally: true, hasUncommittedChanges: false, baseRefAvailable: true, baseRefLabel: "main", @@ -208,6 +215,81 @@ describe("git-actions-policy", () => { ]); }); + it("distinguishes exact local and remote base refs and retains legacy names", () => { + expect(isCurrentBranchBase("release", "refs/heads/release")).toBe(true); + expect(isCurrentBranchBase("release", "refs/remotes/origin/release")).toBe(false); + expect(isCurrentBranchBase("release", "release")).toBe(true); + expect(isCurrentBranchBase("release", "origin/release")).toBe(true); + expect(isCurrentBranchBase(null, "refs/heads/release")).toBe(false); + expect(canMergeIntoLocalBase("release", "refs/heads/release")).toBe(false); + expect(canMergeIntoLocalBase("feature", "refs/remotes/origin/release")).toBe(true); + expect(canMergeIntoLocalBase("release", "refs/remotes/upstream/release")).toBe(false); + }); + + it("offers local merge when the selected local base starts with origin slash", () => { + const actions = buildGitActions( + createInput({ + isPaseoOwnedWorktree: true, + isOnBaseBranch: isCurrentBranchBase("release", "refs/heads/origin/release"), + canMergeLocally: canMergeIntoLocalBase("release", "refs/heads/origin/release"), + baseRefLabel: "origin/release", + aheadCount: 1, + shipDefault: "merge", + }), + ); + expect(actions.primary?.id).toBe("merge-branch"); + }); + + it("offers update but not local merge for a non-origin remote base", () => { + const actions = buildGitActions( + createInput({ + isPaseoOwnedWorktree: true, + isOnBaseBranch: false, + canMergeLocally: canMergeIntoLocalBase("feature", "refs/remotes/upstream/release"), + baseRefLabel: "upstream/release", + aheadCount: 1, + behindBaseCount: 1, + aheadOfOrigin: null, + behindOfOrigin: null, + shipDefault: "merge", + }), + ); + expect(actions.primary?.id).toBe("merge-from-base"); + expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch"); + }); + + it("does not offer merging into the current local branch when the remote base shares its name", () => { + const actions = buildGitActions( + createInput({ + isPaseoOwnedWorktree: true, + isOnBaseBranch: false, + canMergeLocally: canMergeIntoLocalBase("release", "refs/remotes/origin/release"), + baseRefLabel: "release", + aheadCount: 1, + behindBaseCount: 1, + aheadOfOrigin: null, + behindOfOrigin: null, + shipDefault: "merge", + }), + ); + expect(actions.primary?.id).toBe("merge-from-base"); + expect(actionsWithoutPrimary(actions).map((action) => action.id)).not.toContain("merge-branch"); + }); + + it("offers update from the same-named remote base when it is ahead", () => { + const actions = buildGitActions( + createInput({ + isPaseoOwnedWorktree: true, + isOnBaseBranch: isCurrentBranchBase("release", "refs/remotes/origin/release"), + baseRefLabel: "release", + behindBaseCount: 1, + aheadOfOrigin: null, + behindOfOrigin: null, + }), + ); + expect(actions.primary?.id).toBe("merge-from-base"); + }); + it("drops a promoted merge action from the remaining menu actions", () => { const actions = buildGitActions( createInput({ diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index 71d63eae37..9f18e65755 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -50,6 +50,29 @@ interface GitActionRuntimeState { handler: () => void; } +export function isCurrentBranchBase( + currentBranch: string | null | undefined, + baseRef: string | null | undefined, +): boolean { + if (!currentBranch || !baseRef) return false; + if (baseRef.startsWith("refs/heads/")) return baseRef === `refs/heads/${currentBranch}`; + if (baseRef.startsWith("refs/")) return false; + // Older hosts send a display name, which cannot distinguish a local ref from its remote. + return currentBranch === baseRef.replace(/^origin\//, ""); +} + +export function canMergeIntoLocalBase( + currentBranch: string | null | undefined, + baseRef: string | null | undefined, +): boolean { + if (!currentBranch || !baseRef) return false; + if (baseRef.startsWith("refs/remotes/") && !baseRef.startsWith("refs/remotes/origin/")) { + return false; + } + const localTarget = baseRef.replace(/^(?:refs\/heads\/|refs\/remotes\/origin\/|origin\/)/, ""); + return currentBranch !== localTarget; +} + export interface BuildGitActionsInput { isGit: boolean; githubFeaturesEnabled: boolean; @@ -68,6 +91,7 @@ export interface BuildGitActionsInput { hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; + canMergeLocally: boolean; hasUncommittedChanges: boolean; baseRefAvailable: boolean; baseRefLabel: string; @@ -349,7 +373,7 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) { return "pr"; } - if (!input.isOnBaseBranch && input.aheadCount > 0) { + if (!input.isOnBaseBranch && input.canMergeLocally && input.aheadCount > 0) { return "merge-branch"; } if (!input.isOnBaseBranch && canMergeFromBase(input)) { @@ -380,7 +404,7 @@ function getPullRequestActionIds(filter: { function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] { return [ "merge-from-base", - "merge-branch", + ...(input.canMergeLocally ? (["merge-branch"] as const) : []), ...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }), ]; } diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 05e65a21a5..13f7587623 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -12,6 +12,8 @@ import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-st import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { buildGitActions, + isCurrentBranchBase, + canMergeIntoLocalBase, narrowPullRequestState, type BuildGitActionsInput, type GitAction, @@ -116,7 +118,7 @@ interface DeriveGitActionsStateArgs { hasUncommittedChanges: boolean; postShipArchiveSuggested: boolean; isStatusLoading: boolean; - baseRefLabel: string; + baseRef: string | undefined; } interface DerivedGitActionsState { @@ -129,6 +131,7 @@ interface DerivedGitActionsState { hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; + canMergeLocally: boolean; shouldPromoteArchive: boolean; } @@ -167,7 +170,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio hasUncommittedChanges, postShipArchiveSuggested, isStatusLoading, - baseRefLabel, + baseRef, } = args; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; @@ -178,7 +181,8 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio hasPullRequest: Boolean(prStatus?.url), hasRemote: gitStatus?.hasRemote ?? false, isPaseoOwnedWorktree, - isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel, + isOnBaseBranch: isCurrentBranchBase(gitStatus?.currentBranch, baseRef), + canMergeLocally: canMergeIntoLocalBase(gitStatus?.currentBranch, baseRef), shouldPromoteArchive: computeShouldPromoteArchive({ hasUncommittedChanges, postShipArchiveSuggested, @@ -653,7 +657,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasUncommittedChanges, postShipArchiveSuggested, isStatusLoading, - baseRefLabel, + baseRef, }); const { actionsDisabled, @@ -665,6 +669,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, + canMergeLocally, shouldPromoteArchive, } = derived; @@ -695,6 +700,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, + canMergeLocally, hasUncommittedChanges, baseRefAvailable: Boolean(baseRef), baseRefLabel, @@ -812,6 +818,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, + canMergeLocally, githubFeaturesEnabled, forge, githubAutoMergeActionsEnabled, 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..cf260e90d6 --- /dev/null +++ b/packages/app/src/git/workspace-base-branch-picker.tsx @@ -0,0 +1,179 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { GitBranch } from "lucide-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 { 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 { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { invalidateCheckoutGitQueriesForClient } from "./query-keys"; + +interface Props { + serverId: string; + workspaceId?: string | null; + cwd: string; + baseRef: string; + isWorktree: boolean; +} + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedGitBranch = withUnistyles(GitBranch); +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, + isWorktree, +}: 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 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 || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable")); + return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref }); + }, + onSuccess: async () => { + setOpen(false); + 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 = refLabel(baseRef).replace(/^origin\//, ""); + + const { reset, mutate, isPending } = mutation; + const { refetch, isError } = branches; + const handleOpen = useCallback(() => { + reset(); + setSearch(""); + setOpen(true); + }, [reset]); + const handleSelect = useCallback( + (ref: string) => { + if (!isPending) mutate(ref); + }, + [isPending, mutate], + ); + const handleRetry = useCallback(() => { + void refetch(); + }, [refetch]); + const branchIcon = useMemo(() => , []); + const renderOption = useCallback>( + ({ option, selected, active, onPress }) => ( + + ), + [isPending, branchIcon], + ); + const footer = useMemo( + () => ( + + {isPending ? : null} + {error ? ( + + {error.message} + + ) : null} + {isError ? ( + + ) : null} + + ), + [t, isPending, error, isError, handleRetry], + ); + + if (!supported || !isWorktree || !workspaceId) return {label}; + + return ( + + + + + ); +} + +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..9d1ca6610d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -930,6 +930,9 @@ export const ar: TranslationResources = { }, }, diff: { + baseBranch: { + title: "الفرع الأساسي", + }, openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16ba66d960..3f3091ae4a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -938,6 +938,9 @@ export const en = { }, }, diff: { + baseBranch: { + title: "Change base", + }, 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..73cc70a8ca 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -963,6 +963,9 @@ export const es: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Rama base", + }, 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..4257b799aa 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -956,6 +956,9 @@ export const fr: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branche de base", + }, 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..073f164e6b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -943,6 +943,9 @@ export const ja: TranslationResources = { }, }, diff: { + baseBranch: { + title: "ベースブランチ", + }, openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 117d5d68bc..cee2bb19bd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -937,6 +937,9 @@ export const ko: TranslationResources = { }, }, diff: { + baseBranch: { + title: "기준 브랜치", + }, 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..a88a749709 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -953,6 +953,9 @@ export const ptBR: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branch base", + }, 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..3d8bd13196 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -947,6 +947,9 @@ export const ru: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Базовая ветка", + }, 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..f0a0c9bc5c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -922,6 +922,9 @@ export const zhCN: TranslationResources = { }, }, diff: { + baseBranch: { + title: "基准分支", + }, openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 68677c3f24..b29a34111c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3582,6 +3582,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, @@ -5134,7 +5150,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({ @@ -5144,6 +5160,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 62ee8dda6a..5fd4d92958 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 568c23896b..ead91e719c 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. @@ -862,6 +866,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 b5fa571914..64f37064e2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1040,6 +1040,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(), @@ -2197,6 +2204,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"), @@ -2514,6 +2532,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(), }); @@ -3328,6 +3347,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, WorkspacePinSetRequestSchema, + WorkspaceBaseBranchSetRequestSchema, WorkspaceLabelListRequestSchema, WorkspaceLabelAssignmentSetRequestSchema, WorkspaceLabelUpdateRequestSchema, @@ -3712,6 +3732,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. @@ -5405,6 +5427,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 @@ -7203,6 +7226,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveResponseSchema, WorkspaceTitleSetResponseSchema, WorkspacePinSetResponseSchema, + WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, WaitForFinishResponseMessageSchema, @@ -7564,6 +7588,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 9ce3fbbcb4..3dec72f45f 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, type BackgroundVisibility } 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"; @@ -483,6 +489,7 @@ export interface SessionOptions { creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceBaseBranch?: WorkspaceBaseBranchService; directorySync?: DirectorySyncService; workspaceLabelService?: WorkspaceLabelService; filesystem?: SessionFileSystem; @@ -779,6 +786,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"]; @@ -962,12 +970,22 @@ export class Session { this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; this.workspaceRuntimeStore = workspaceRuntimeStore; + this.workspaceBaseBranch = + options.workspaceBaseBranch ?? + createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock, lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, @@ -3213,6 +3231,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; } @@ -4076,6 +4096,41 @@ export class Session { } } + private async handleWorkspaceBaseBranchSetRequest( + msg: WorkspaceBaseBranchSetRequest, + ): Promise { + try { + this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + 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..ffc8feffb0 --- /dev/null +++ b/packages/server/src/server/session/workspace-base-branch/service.ts @@ -0,0 +1,104 @@ +import { resolve } from "node:path"; +import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js"; +import { + readPaseoWorktreeMetadata, + setPaseoWorktreeBaseRef, +} from "../../../utils/worktree-metadata.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; + +interface Dependencies { + paseoHome: string; + worktreesRoot?: string; + workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; +} + +export function createWorkspaceBaseBranchService(deps: Dependencies) { + const queues = new Map>(); + + async function withCheckoutLock(root: string, operation: () => Promise): Promise { + root = resolve(root); + const previous = queues.get(root); + let release!: () => void; + const queued = new Promise((done) => { + release = done; + }); + queues.set(root, queued); + await previous; + + try { + return await operation(); + } finally { + release(); + if (queues.get(root) === queued) queues.delete(root); + } + } + + 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, { + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + if (!facts.isGit) throw new Error("Workspace is not a Git checkout"); + if (!facts.paseoWorktree.isPaseoOwnedWorktree) + throw new Error("Workspace is not a Paseo worktree"); + const root = resolve(facts.worktreeRoot); + return withCheckoutLock(root, async () => { + const current = await deps.workspaceRegistry.get(input.workspaceId); + if (!current || current.archivedAt) throw new Error("Workspace is unavailable"); + const records = await deps.workspaceRegistry.listActive(); + const matching = records.filter( + (record) => + record.workspaceId === input.workspaceId || + resolve(record.worktreeRoot ?? record.cwd) === root, + ); + await deps.workspaceGitService.getSnapshot(root); + const metadata = await readPaseoWorktreeMetadata(root); + const baseRef = await setCheckoutBaseBranch({ + cwd: root, + baseRef: input.baseRef, + context: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot }, + }); + 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) + await setPaseoWorktreeBaseRef({ + worktreeRoot: root, + baseRef: metadata.baseRef ?? metadata.baseRefName, + }); + 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, + afterInFlight: true, + includeForge: false, + reason: "base-branch-change", + }); + } + return baseRef; + }); + } + + return { setBaseBranch, withCheckoutLock }; +} + +export type WorkspaceBaseBranchService = ReturnType; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index d9f7ccea96..012ad0d490 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -5,6 +5,11 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getCheckoutStatus } from "../../../utils/checkout-git.js"; +import { + setPaseoWorktreeBaseRef, + writePaseoWorktreeMetadata, +} from "../../../utils/worktree-metadata.js"; +import { createWorkspaceBaseBranchService } from "../workspace-base-branch/service.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createNoGitWorkspaceRuntimeSnapshot, @@ -125,6 +130,62 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +test("a workspace created during a base change records the new base", async () => { + const cwd = path.join(tmpDir, "worktree"); + mkdirSync(path.join(cwd, ".git"), { recursive: true }); + await writePaseoWorktreeMetadata(cwd, { baseRefName: "main", baseRef: "refs/heads/main" }); + const git = createNoopWorkspaceGitService({ + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "feature", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: true, + mainRepoRoot: cwd, + }), + }); + const bases = createWorkspaceBaseBranchService({ + paseoHome: tmpDir, + workspaceRegistry, + workspaceGitService: git, + }); + let release!: () => void; + let entered!: () => void; + let attempted!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const locked = new Promise((resolve) => { + entered = resolve; + }); + const waiting = new Promise((resolve) => { + attempted = resolve; + }); + const change = bases.withCheckoutLock(cwd, async () => { + entered(); + await gate; + await setPaseoWorktreeBaseRef({ worktreeRoot: cwd, baseRef: "refs/heads/release" }); + }); + await locked; + const creator = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: git, + isDirectory, + logger, + withCheckoutLock: (root, operation) => { + attempted(); + return bases.withCheckoutLock(root, operation); + }, + }); + const created = creator.createWorkspaceForDirectory(cwd); + await waiting; + release(); + await change; + expect((await created).baseBranch).toBe("refs/heads/release"); +}); + test("fresh git repo creates a workspace at the canonical worktree root", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); 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 c4554688e0..3f2a7b423c 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; @@ -107,6 +108,7 @@ export function createWorkspaceProvisioningService(deps: { isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; + withCheckoutLock?: (root: string, operation: () => Promise) => Promise; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; @@ -253,21 +255,31 @@ export function createWorkspaceProvisioningService(deps: { ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. await findOrCreateProjectForDirectory(normalizedCwd); - const timestamp = new Date().toISOString(); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: context?.workspaceId ?? generateWorkspaceId(), - projectId: project.projectId, - ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), - title: title?.trim() || null, - createdAt: timestamp, - updatedAt: timestamp, - background: await resolveBackground(context), - }); - await workspaceRegistry.upsert(workspace, { - expectsInitialAgent: context?.expectsInitialAgent, - }); - emitWorkspaceCreated(workspace); - return workspace; + const create = async () => { + const baseMetadata = + checkout.isGit && checkout.isPaseoOwnedWorktree + ? await readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd) + : null; + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + 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, + background: await resolveBackground(context), + }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); + return workspace; + }; + return deps.withCheckoutLock && checkout.isGit && checkout.isPaseoOwnedWorktree + ? deps.withCheckoutLock(checkout.worktreeRoot ?? normalizedCwd, create) + : create(); } async function createWorkspaceForWorktree( diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 61014327fd..7c301556c3 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"; @@ -569,6 +573,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; @@ -756,12 +761,18 @@ export class VoiceAssistantWebSocketServer { this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); + this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; + this.workspaceBaseBranch = createWorkspaceBaseBranchService({ + paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); this.passwordHash = auth?.password; - this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; this.assignOptionalServices({ @@ -1661,6 +1672,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, @@ -2016,6 +2028,7 @@ export class VoiceAssistantWebSocketServer { backgroundWorkspaces: true, // COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03. backgroundAgents: 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..d10a098d79 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 @@ -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,118 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test("a local checkout rejects a base change without creating worktree metadata", async () => { + const repo = createGitRepo(); + const id = await createLocalWorkspace(repo, "Local"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/main" }), + ).rejects.toThrow("not a Paseo worktree"); + expect(await readPaseoWorktreeMetadata(repo)).toBeNull(); +}); + +test("changing a worktree base refreshes its diff and persists across restart", async () => { + const repo = createGitRepo(); + execFileSync("git", ["checkout", "-b", "release"], { cwd: repo }); + writeFileSync(path.join(repo, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + const cwd = workspace.workspaceDirectory; + execFileSync("git", ["merge", "--ff-only", "release"], { cwd }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).not.toContain("release.txt"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: workspace.id, baseRef: "refs/heads/missing" }), + ).rejects.toThrow("Base branch not found"); + execFileSync("git", ["update-ref", "refs/remotes/origin/vanished", "release"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/remotes/origin/vanished", + }); + execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/vanished"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/main", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.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.getCheckoutStatus(cwd)).baseRefExact).toBe("refs/heads/release"); +}, 30_000); + +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((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe( + "refs/heads/release", + ); + expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe( + "refs/heads/release", + ); +}); + +test("an archived workspace keeps its base when a later workspace reuses its path", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const source = { kind: "worktree", cwd: repo, worktreeSlug: "reused-path", baseBranch: "main" }; + const first = (await ctx.client.createWorkspace({ source })).workspace; + if (!first) throw new Error("Failed to create first workspace"); + expect((await ctx.client.archiveWorkspace(first.id)).error).toBe(null); + expect(existsSync(first.workspaceDirectory)).toBe(false); + const second = (await ctx.client.createWorkspace({ source })).workspace; + if (!second) throw new Error("Failed to create second workspace"); + expect(second.workspaceDirectory).toBe(first.workspaceDirectory); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: second.id, + baseRef: "refs/heads/release", + }); + expect((await ctx.client.archiveWorkspace(second.id)).error).toBe(null); + await ctx.client.restoreWorkspace(first.id); + expect((await readPaseoWorktreeMetadata(first.workspaceDirectory))?.baseRef).toBe( + "refs/heads/main", + ); +}); + 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.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 490807f8d6..d87e896ead 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -766,6 +766,35 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("a refresh requested after a mutation waits for a new snapshot", async () => { + const gate = createDeferred(); + let reads = 0; + const service = createService({ + getCheckoutStatus: (cwd: string) => { + reads += 1; + return reads === 1 + ? gate.promise + : Promise.resolve(createCheckoutStatus(cwd, { currentBranch: "new-base" })); + }, + }); + const first = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "before-mutation", + }); + await vi.waitFor(() => expect(reads).toBe(1)); + const after = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "after-mutation", + afterInFlight: true, + }); + gate.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "old-base" })); + await first; + expect((await after).git.currentBranch).toBe("new-base"); + service.dispose(); + }); + test("bounds refresh generations and re-enqueues hot workspace successors fairly", async () => { const hotCwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY }, (_, index) => path.resolve(`/tmp/hot-repo-${index}`), diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 668373e998..ea91186e9b 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), ); @@ -3573,6 +3581,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, @@ -3580,6 +3589,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) { @@ -3612,6 +3625,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 474fc24829..351ba043e4 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..075f7ebda1 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -32,6 +32,7 @@ import { getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, + setCheckoutBaseBranch, mergeToBase, mergeFromBase, MergeConflictError, @@ -263,6 +264,56 @@ describe("checkout git utilities", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("rejects changing the base of a local checkout without creating metadata", async () => { + await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" })).rejects.toThrow(); + expect(existsSync(await getPaseoWorktreeMetadataPath(repoDir))).toBe(false); + }); + + 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 = await readPaseoWorktreeMetadata(worktree.worktreePath); + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + await setCheckoutBaseBranch({ + cwd: worktree.worktreePath, + baseRef: "release", + context: { paseoHome }, + }); + expect(await 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("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); mkdirSync(nonGitDir, { recursive: true }); @@ -1920,6 +1971,13 @@ const x = 1; expect(status.currentBranch).toBe("feature/plain"); }); + it("rejects merging a branch into its own same-named remote base", async () => { + execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir }); + await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow( + "Cannot merge a branch into itself", + ); + }); + it("merges the current branch into base from a worktree checkout", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "main", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index a0cbaa7afc..d793e2c683 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,36 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context }); - if (!paseoWorktree.isPaseoOwnedWorktree) { - return null; - } + const ownership = await getPaseoWorktreeForCwd(cwd, { context }); + return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null; +} - return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot); +export async function setCheckoutBaseBranch(input: { + cwd: string; + baseRef: string; + context?: CheckoutContext; +}): Promise { + await requireGitRepo(input.cwd); + const ownership = await getPaseoWorktreeForCwd(input.cwd, { context: input.context }); + if (!ownership.isPaseoOwnedWorktree) throw new Error("Workspace is not a Paseo worktree"); + 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); + await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate }); + return candidate; + } + throw new Error(`Base branch not found: ${requested}`); } async function getResolvedBaseRefForCwd( @@ -1786,7 +1828,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 +2174,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 +2420,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef ?? baseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2391,6 +2439,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2605,7 +2654,10 @@ async function tryResolveCheckoutCommitsBaseRef( return null; } const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } try { @@ -3890,7 +3942,7 @@ export async function mergeToBase( const normalizedBaseRef = resolveMergeTargetBranch(baseRef); const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd; if (normalizedBaseRef === currentBranch) { - return currentWorktreeRoot; + throw new Error("Cannot merge a branch into itself"); } const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef); diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index fcb0454672..fc4488d742 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,21 @@ 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 },