From fbcfa86ee09e9f3befb0c36d24504764db05e40c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 07:06:55 +0100 Subject: [PATCH] Shape base picker around managed worktrees Co-authored-by: Tianyu Yang --- packages/app/e2e/browser/changes-pane.spec.ts | 48 ++++++-- packages/app/src/git/diff-pane.tsx | 18 ++- .../src/git/workspace-base-branch-picker.tsx | 27 +++-- packages/app/src/i18n/resources/en.ts | 2 +- packages/server/src/server/session.ts | 12 +- .../session/workspace-base-branch/service.ts | 56 ++++++--- .../workspace-provisioning-service.test.ts | 61 ++++++++++ .../workspace-provisioning-service.ts | 45 ++++--- .../server/src/server/websocket-server.ts | 4 +- ...orkspace-archive-record-scoped.e2e.test.ts | 113 ++++++++---------- .../src/server/workspace-git-service.test.ts | 29 +++++ .../server/src/utils/checkout-git.test.ts | 101 ++-------------- packages/server/src/utils/checkout-git.ts | 9 +- .../server/src/utils/worktree-metadata.ts | 5 +- 14 files changed, 311 insertions(+), 219 deletions(-) diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 96d0d225b5..2e8353035c 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -326,7 +326,7 @@ 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(); + const workspace = await createWorktreeWithCommittedDiff(); execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], { cwd: workspace.repoPath, @@ -355,8 +355,22 @@ test("Changes selects exact local and remote bases and retains the selection aft ); }); +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"); +}); + test("Changes shows a failed base selection and lets the user retry", async ({ page }) => { - const workspace = await createWorkspaceWithCommittedDiff(); + const workspace = await createWorktreeWithCommittedDiff(); execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath }); await openWorkspaceChangesSurface(page, workspace); const picker = page @@ -404,17 +418,13 @@ test("Changes asks to update a host without the base-branch capability", async ( browser.send(frame); }); }); - const workspace = await createWorkspaceWithCommittedDiff(); + const workspace = await createWorktreeWithCommittedDiff(); 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(); + 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); @@ -1748,6 +1758,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/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 9ef9780a9a..5f370dfea8 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -471,6 +471,7 @@ interface ChangesPullRequestLinkModel extends Pick{children}; } +function isManagedWorktree(status: { isPaseoOwnedWorktree?: boolean } | null | undefined): boolean { + return status?.isPaseoOwnedWorktree === true; +} + function ChangesRepositoryToolbar({ compact, model, @@ -658,6 +662,8 @@ function ChangesRepositoryToolbar({ isGitCheckout testID="changes-branch-switcher" /> + → + {model.baseBranchPicker} {model.pullRequest ? ( @@ -757,7 +763,6 @@ function ChangesComparisonToolbar({ onSelectBase={model.onSelectBase} onSelectAll={model.onSelectAll} /> - {model.diffMode === "base" ? model.baseBranchPicker : null} {model.selectedDiffStat ? ( buildChangesHeaderModel({ - baseBranchPicker: workspaceId ? ( + baseBranchPicker: ( - ) : null, + ), branchName: currentBranchName, committedDescription: committedDiffDescription, compact: isMobile, @@ -1935,6 +1941,7 @@ export function ChangesSurface({ }), [ baseRef, + status, committedDiffDescription, currentBranchName, cwd, @@ -2005,6 +2012,7 @@ export function ChangesSurface({ } const styles = StyleSheet.create((theme) => ({ + baseArrow: { color: theme.colors.foregroundMuted }, container: { flex: 1, minHeight: 0, diff --git a/packages/app/src/git/workspace-base-branch-picker.tsx b/packages/app/src/git/workspace-base-branch-picker.tsx index 913b760291..0c8f627bd0 100644 --- a/packages/app/src/git/workspace-base-branch-picker.tsx +++ b/packages/app/src/git/workspace-base-branch-picker.tsx @@ -1,5 +1,6 @@ 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"; @@ -18,19 +19,27 @@ import { invalidateCheckoutGitQueriesForClient } from "./query-keys"; interface Props { serverId: string; - workspaceId: string; + workspaceId?: string | null; cwd: string; baseRef: string | null; + 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 }: Props) { +export function WorkspaceBaseBranchPicker({ + serverId, + workspaceId, + cwd, + baseRef, + isWorktree, +}: Props) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const connected = useHostRuntimeIsConnected(serverId); @@ -64,7 +73,7 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }); const mutation = useMutation({ mutationFn: async (ref: string) => { - if (!client) throw new Error(t("common.errors.daemonClientUnavailable")); + if (!client || !workspaceId) throw new Error(t("common.errors.daemonClientUnavailable")); return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref }); }, onSuccess: async () => { @@ -79,7 +88,7 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef ); const error = mutation.error ?? branches.error; const title = t("workspace.git.diff.baseBranch.title"); - const label = baseRef ? refLabel(baseRef) : title; + const label = baseRef ? refLabel(baseRef).replace(/^origin\//, "") : title; const hint = supported ? t("workspace.git.diff.baseBranch.hint") : t("workspace.git.diff.baseBranch.updateHost"); @@ -104,23 +113,23 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef const handleRetry = useCallback(() => { void refetch(); }, [refetch]); + const branchIcon = useMemo(() => , []); const renderOption = useCallback>( ({ option, selected, active, onPress }) => ( ), - [isPending], + [isPending, branchIcon], ); const footer = useMemo( () => ( - {t("workspace.git.diff.baseBranch.hint")} {isPending ? : null} {error ? ( @@ -137,6 +146,8 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef [t, isPending, error, isError, handleRetry], ); + if (!supported || !isWorktree || !workspaceId) return {label}; + return ( @@ -173,7 +184,7 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef desktopPreventInitialFlash desktopMinWidth={300} renderOption={renderOption} - footer={footer} + footer={error || isPending ? footer : undefined} /> ); diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7de132eabe..3f60ffcae8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -939,7 +939,7 @@ export const en = { }, diff: { baseBranch: { - title: "Base branch", + title: "Change base", 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", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0292aea739..75a8280093 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -963,7 +963,14 @@ export class Session { this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; this.workspaceRuntimeStore = workspaceRuntimeStore; - this.workspaceBaseBranch = options.workspaceBaseBranch; + this.workspaceBaseBranch = + options.workspaceBaseBranch ?? + createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, @@ -971,6 +978,7 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock, lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, @@ -4075,6 +4083,8 @@ export class Session { ): Promise { try { this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, workspaceRegistry: this.workspaceRegistry, workspaceGitService: this.workspaceGitService, }); diff --git a/packages/server/src/server/session/workspace-base-branch/service.ts b/packages/server/src/server/session/workspace-base-branch/service.ts index 9685849d1d..13e7a17297 100644 --- a/packages/server/src/server/session/workspace-base-branch/service.ts +++ b/packages/server/src/server/session/workspace-base-branch/service.ts @@ -1,15 +1,15 @@ -import { unlinkSync } from "node:fs"; import { resolve } from "node:path"; import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js"; import { - getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata, - writePaseoWorktreeMetadataFile, + 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; } @@ -17,12 +17,8 @@ interface Dependencies { 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); + async function withCheckoutLock(root: string, operation: () => Promise): Promise { + root = resolve(root); const previous = queues.get(root); let release!: () => void; const queued = new Promise((done) => { @@ -32,6 +28,25 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) { 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.list(); @@ -41,8 +56,12 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) { 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 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) { @@ -53,8 +72,11 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) { })); } } catch (error) { - if (metadata) writePaseoWorktreeMetadataFile(root, metadata); - else unlinkSync(getPaseoWorktreeMetadataPath(root)); + if (metadata) + await setPaseoWorktreeBaseRef({ + worktreeRoot: root, + baseRef: metadata.baseRef ?? metadata.baseRefName, + }); for (const record of matching) { await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ ...latest, @@ -67,18 +89,16 @@ export function createWorkspaceBaseBranchService(deps: Dependencies) { 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; - } finally { - release(); - if (queues.get(root) === queued) queues.delete(root); - } + }); } - return { setBaseBranch }; + 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 c93e535709..9f73a6e87c 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 d5c5cd88d8..2e7097f5bc 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 @@ -108,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; @@ -249,29 +250,35 @@ 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. await findOrCreateProjectForDirectory(normalizedCwd); - 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; + 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 0152bd4c19..8c94e73c59 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -760,7 +760,10 @@ 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, }); @@ -769,7 +772,6 @@ export class VoiceAssistantWebSocketServer { 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({ 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 dd5a6295d6..6078036dfb 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, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -278,79 +278,66 @@ test("renaming a workspace updates every subscribed client", async () => { } }); -test("changing a checkout base updates subscribed clients and shared workspace recovery records", 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", "."], { cwd: repo }); + execFileSync("git", ["add", "release.txt"], { 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 }, + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" }, }); - 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 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 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"); -}); + 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(); @@ -373,7 +360,7 @@ test("a managed workspace restores its changed base after archival removes the c 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( + expect((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe( "refs/heads/release", ); expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe( diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index a7a3b49f06..21fd1b0c92 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -764,6 +764,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", + afterCurrent: 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/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index eb45d69c42..12abec88ff 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -32,7 +32,6 @@ import { getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, - listCheckoutCommits, setCheckoutBaseBranch, mergeToBase, mergeFromBase, @@ -265,54 +264,9 @@ 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("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 () => { @@ -324,10 +278,14 @@ describe("checkout git utilities", () => { paseoHome, runSetup: false, }); - const original = readPaseoWorktreeMetadata(worktree.worktreePath); + const original = await readPaseoWorktreeMetadata(worktree.worktreePath); execFileSync("git", ["branch", "release"], { cwd: repoDir }); - await setCheckoutBaseBranch({ cwd: worktree.worktreePath, baseRef: "release" }); - expect(readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({ + await setCheckoutBaseBranch({ + cwd: worktree.worktreePath, + baseRef: "release", + context: { paseoHome }, + }); + expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({ ...original, baseRefName: "release", baseRef: "refs/heads/release", @@ -356,45 +314,6 @@ describe("checkout git utilities", () => { }); }); - 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 eda96de62c..40507c54c5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1326,15 +1326,18 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const worktreeRoot = await getWorktreeRoot(cwd, context); - return worktreeRoot ? readPaseoWorktreeBaseRef(worktreeRoot) : null; + const ownership = await getPaseoWorktreeForCwd(cwd, { context }); + return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null; } 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 @@ -1349,7 +1352,7 @@ export async function setCheckoutBaseBranch(input: { 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 }); + await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate }); return candidate; } throw new Error(`Base branch not found: ${requested}`); diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 067a5ff608..fc4488d742 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -249,7 +249,10 @@ export async function writePaseoWorktreeMetadata( }); } -export async function setPaseoWorktreeBaseRef(input: { worktreeRoot: string; baseRef: string }): Promise { +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");