Shape base picker around managed worktrees

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
Mohamed Boudra
2026-11-03 07:53:14 +01:00
co-authored by Tianyu Yang
parent 6bf012217a
commit fbcfa86ee0
14 changed files with 311 additions and 219 deletions
+40 -8
View File
@@ -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<vo
}).toPass({ timeout: 15_000 });
}
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
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<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
+13 -5
View File
@@ -471,6 +471,7 @@ interface ChangesPullRequestLinkModel extends Pick<PrHint, "forge" | "number" |
}
interface ChangesRepositoryToolbarModel {
baseBranchPicker: ReactNode;
branchName: string | null;
cwd: string;
gitActions: GitActions | null;
@@ -480,7 +481,6 @@ interface ChangesRepositoryToolbarModel {
}
interface ChangesComparisonToolbarModel {
baseBranchPicker: ReactNode;
committedDescription?: string;
diffMode: "uncommitted" | "base" | "all";
mode: ChangesToolbarMode;
@@ -522,6 +522,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
} {
return {
repository: {
baseBranchPicker: input.baseBranchPicker,
branchName: input.branchName,
cwd: input.cwd,
gitActions: input.compact ? input.gitActions : null,
@@ -532,7 +533,6 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
workspaceId: input.workspaceId,
},
comparison: {
baseBranchPicker: input.baseBranchPicker,
committedDescription: input.committedDescription,
diffMode: input.diffMode,
mode: input.mode,
@@ -633,6 +633,10 @@ function ChangesToolbarTrailing({ children }: { children: ReactNode }) {
return <ToolbarControls style={styles.changesToolbarControls}>{children}</ToolbarControls>;
}
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"
/>
<Text style={styles.baseArrow}>→</Text>
{model.baseBranchPicker}
</ChangesToolbarLeading>
<ChangesToolbarTrailing>
{model.pullRequest ? (
@@ -757,7 +763,6 @@ function ChangesComparisonToolbar({
onSelectBase={model.onSelectBase}
onSelectAll={model.onSelectAll}
/>
{model.diffMode === "base" ? model.baseBranchPicker : null}
{model.selectedDiffStat ? (
<DiffStat
additions={model.selectedDiffStat.additions}
@@ -1909,14 +1914,15 @@ export function ChangesSurface({
const changesHeaderModel = useMemo(
() =>
buildChangesHeaderModel({
baseBranchPicker: workspaceId ? (
baseBranchPicker: (
<WorkspaceBaseBranchPicker
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
baseRef={baseRef ?? null}
isWorktree={isManagedWorktree(status)}
/>
) : 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,
@@ -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(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
({ option, selected, active, onPress }) => (
<ComboboxItem
label={option.label}
description={option.id}
leadingSlot={branchIcon}
selected={selected}
active={active}
onPress={onPress}
disabled={isPending}
/>
),
[isPending],
[isPending, branchIcon],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Text style={styles.text}>{t("workspace.git.diff.baseBranch.hint")}</Text>
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
{error ? (
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
@@ -137,6 +146,8 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef
[t, isPending, error, isError, handleRetry],
);
if (!supported || !isWorktree || !workspaceId) return <Text style={styles.text}>{label}</Text>;
return (
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
@@ -173,7 +184,7 @@ export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef
desktopPreventInitialFlash
desktopMinWidth={300}
renderOption={renderOption}
footer={footer}
footer={error || isPending ? footer : undefined}
/>
</View>
);
+1 -1
View File
@@ -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",
+11 -1
View File
@@ -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<void> {
try {
this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
workspaceRegistry: this.workspaceRegistry,
workspaceGitService: this.workspaceGitService,
});
@@ -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<string, Promise<void>>();
async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise<string> {
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<T>(root: string, operation: () => Promise<T>): Promise<T> {
root = resolve(root);
const previous = queues.get(root);
let release!: () => void;
const queued = new Promise<void>((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<string> {
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<typeof createWorkspaceBaseBranchService>;
@@ -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<void>((resolve) => {
release = resolve;
});
const locked = new Promise<void>((resolve) => {
entered = resolve;
});
const waiting = new Promise<void>((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);
@@ -108,6 +108,7 @@ export function createWorkspaceProvisioningService(deps: {
isDirectory: (path: string) => Promise<boolean>;
logger: Logger;
lifecycle?: PluginLifecycle;
withCheckoutLock?: <T>(root: string, operation: () => Promise<T>) => Promise<T>;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
@@ -249,29 +250,35 @@ export function createWorkspaceProvisioningService(deps: {
): Promise<PersistedWorkspaceRecord> {
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(
@@ -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({
@@ -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(
@@ -764,6 +764,35 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("a refresh requested after a mutation waits for a new snapshot", async () => {
const gate = createDeferred<CheckoutStatusGit>();
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}`),
+10 -91
View File
@@ -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 });
+6 -3
View File
@@ -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<string> {
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}`);
@@ -249,7 +249,10 @@ export async function writePaseoWorktreeMetadata(
});
}
export async function setPaseoWorktreeBaseRef(input: { worktreeRoot: string; baseRef: string }): Promise<void> {
export async function setPaseoWorktreeBaseRef(input: {
worktreeRoot: string;
baseRef: string;
}): Promise<void> {
return mutateMetadata(input.worktreeRoot, async (root) => {
const current = await readMetadataFile(root);
if (!current) throw new Error("Cannot change base: missing worktree metadata");