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 <yangtianyu.cl@bytedance.com>
This commit is contained in:
Mohamed Boudra
2026-11-05 22:30:14 +07:00
committed by GitHub
co-authored by Tianyu Yang
parent e9f897a66a
commit 3ed57e9dd7
41 changed files with 1192 additions and 46 deletions
+5 -1
View File
@@ -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) | |
@@ -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<string, unknown> } };
};
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<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" }],
@@ -25,7 +25,7 @@ export async function fetchCheckoutStatus({
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
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<T extends CheckoutStatusPayload>(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,
+29
View File
@@ -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<PrHint, "forge" | "number" |
}
interface ChangesRepositoryToolbarModel {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
cwd: string;
gitActions: GitActions | null;
@@ -496,6 +501,8 @@ interface ChangesHeaderProps {
}
interface BuildChangesHeaderModelInput {
baseRef: string | null;
isWorktree: boolean;
branchName: string | null;
committedDescription?: string;
compact: boolean;
@@ -519,6 +526,8 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
} {
return {
repository: {
baseRef: input.baseRef,
isWorktree: input.isWorktree,
branchName: input.branchName,
cwd: input.cwd,
gitActions: input.compact ? input.gitActions : null,
@@ -629,6 +638,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,
@@ -654,6 +667,18 @@ function ChangesRepositoryToolbar({
isGitCheckout
testID="changes-branch-switcher"
/>
{model.baseRef ? (
<>
<ThemedArrowRight size={14} uniProps={foregroundMutedIconColorMapping} />
<WorkspaceBaseBranchPicker
serverId={model.serverId}
workspaceId={model.workspaceId}
cwd={model.cwd}
baseRef={model.baseRef}
isWorktree={model.isWorktree}
/>
</>
) : null}
</ChangesToolbarLeading>
<ChangesToolbarTrailing>
{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,
+83 -1
View File
@@ -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({
+26 -2
View File
@@ -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 }),
];
}
+11 -4
View File
@@ -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,
@@ -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<View>(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(() => <ThemedGitBranch size={14} uniProps={mutedColor} />, []);
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
({ option, selected, active, onPress }) => (
<ComboboxItem
label={option.label}
leadingSlot={branchIcon}
selected={selected}
active={active}
onPress={onPress}
disabled={isPending}
/>
),
[isPending, branchIcon],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
{isPending ? <ThemedLoadingSpinner size="small" uniProps={mutedColor} /> : null}
{error ? (
<Text accessibilityRole="alert" testID="workspace-base-branch-error" style={styles.error}>
{error.message}
</Text>
) : null}
{isError ? (
<Button variant="outline" onPress={handleRetry}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
),
[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}>
<ToolbarLabelSelectTrigger
testID="workspace-base-branch-trigger"
label={label}
open={open}
disabled={!connected || mutation.isPending}
accessibilityRole="button"
accessibilityLabel={title}
onPress={handleOpen}
/>
<Combobox
options={options}
value={baseRef}
onSelect={handleSelect}
keepOpenOnSelect
searchable
onSearchQueryChange={setSearch}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={branches.isFetching ? t("common.states.loading") : t("branchSwitcher.empty")}
title={title}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={300}
renderOption={renderOption}
footer={error || isPending ? footer : undefined}
/>
</View>
);
}
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 },
}));
+3
View File
@@ -930,6 +930,9 @@ export const ar: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "الفرع الأساسي",
},
openChangesTab: "فتح علامة تبويب التغييرات",
openDiffTab: "فتح علامة تبويب الفرق",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
+3
View File
@@ -938,6 +938,9 @@ export const en = {
},
},
diff: {
baseBranch: {
title: "Change base",
},
openChangesTab: "Open Changes tab",
openDiffTab: "Open Diff tab",
closeChangesTab: "Close Changes tab",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -943,6 +943,9 @@ export const ja: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "ベースブランチ",
},
openChangesTab: "変更タブを開く",
openDiffTab: "差分タブを開く",
closeChangesTab: "変更タブを閉じる",
+3
View File
@@ -937,6 +937,9 @@ export const ko: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "기준 브랜치",
},
openChangesTab: "변경사항 탭 열기",
openDiffTab: "Diff 탭 열기",
closeChangesTab: "변경사항 탭 닫기",
+3
View File
@@ -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",
+3
View File
@@ -947,6 +947,9 @@ export const ru: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "Базовая ветка",
},
openChangesTab: "Открыть вкладку «Изменения»",
openDiffTab: "Открыть вкладку «Дифф»",
closeChangesTab: "Закрыть вкладку «Изменения»",
+3
View File
@@ -922,6 +922,9 @@ export const zhCN: TranslationResources = {
},
},
diff: {
baseBranch: {
title: "基准分支",
},
openChangesTab: "打开“更改”标签页",
openDiffTab: "打开 Diff 标签页",
closeChangesTab: "关闭“更改”标签页",
+18 -1
View File
@@ -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<BranchSuggestionsPayload> {
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",
});
+23
View File
@@ -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(
+6
View File
@@ -189,6 +189,10 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
setBaseBranch(options: {
baseRef: string;
requestId?: string;
}): Promise<{ baseRef: string | null }>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* 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) {
+25
View File
@@ -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 `<branch>@{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<typeof ProjectIconSetRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type WorkspaceBaseBranchSetRequest = z.infer<typeof WorkspaceBaseBranchSetRequestSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
@@ -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",
@@ -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",
@@ -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,
@@ -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,
@@ -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 }],
@@ -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",
+55
View File
@@ -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<CreationService, "create" | "subscribe">;
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<void> {
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,
@@ -379,13 +379,14 @@ export class CheckoutSession {
}
async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise<void> {
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",
@@ -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<string, Promise<void>>();
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) => {
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<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.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<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);
@@ -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<boolean>;
logger: Logger;
lifecycle?: PluginLifecycle;
withCheckoutLock?: <T>(root: string, operation: () => Promise<T>) => Promise<T>;
}): 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(
+14 -1
View File
@@ -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.
@@ -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) => {
@@ -766,6 +766,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",
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}`),
@@ -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,
@@ -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
@@ -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()
@@ -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",
+64 -12
View File
@@ -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<BranchSuggestion[]> {
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<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
? [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<AheadBehind | null> {
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);
+16 -1
View File
@@ -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<void> {
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 },