mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
feat: allow changing the workspace base branch
This commit is contained in:
+5
-1
@@ -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,7 +323,98 @@ test("All changes combines committed and pending edits and preserves an unfinish
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const picker = tree.getByTestId("workspace-base-branch-trigger");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.click();
|
||||
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
|
||||
await page.getByRole("button", { name: "release", exact: true }).click();
|
||||
await expect(picker).toContainText("release");
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await picker.click();
|
||||
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
|
||||
head,
|
||||
);
|
||||
});
|
||||
|
||||
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const picker = page
|
||||
.getByTestId("workspace-base-branch-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await picker.click();
|
||||
const option = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
|
||||
"Base branch not found",
|
||||
);
|
||||
await expect(picker).toContainText("main");
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
|
||||
await expect(picker).toContainText("release");
|
||||
});
|
||||
|
||||
test("Changes asks to update a host without the base-branch capability", async ({ page }) => {
|
||||
let baseRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as { message?: { type?: string } };
|
||||
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as {
|
||||
message?: { payload?: { status?: string; features?: Record<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 createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await page.getByTestId("workspace-base-branch-trigger").filter({ visible: true }).first().click();
|
||||
await expect(
|
||||
page.getByText("Update the host to change the base branch.", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import type { GitActions } from "@/git/policy";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { WorkspaceBaseBranchPicker } from "@/git/workspace-base-branch-picker";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
@@ -479,6 +480,7 @@ interface ChangesRepositoryToolbarModel {
|
||||
}
|
||||
|
||||
interface ChangesComparisonToolbarModel {
|
||||
baseBranchPicker: ReactNode;
|
||||
committedDescription?: string;
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
mode: ChangesToolbarMode;
|
||||
@@ -496,6 +498,7 @@ interface ChangesHeaderProps {
|
||||
}
|
||||
|
||||
interface BuildChangesHeaderModelInput {
|
||||
baseBranchPicker: ReactNode;
|
||||
branchName: string | null;
|
||||
committedDescription?: string;
|
||||
compact: boolean;
|
||||
@@ -529,6 +532,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
|
||||
workspaceId: input.workspaceId,
|
||||
},
|
||||
comparison: {
|
||||
baseBranchPicker: input.baseBranchPicker,
|
||||
committedDescription: input.committedDescription,
|
||||
diffMode: input.diffMode,
|
||||
mode: input.mode,
|
||||
@@ -753,6 +757,7 @@ function ChangesComparisonToolbar({
|
||||
onSelectBase={model.onSelectBase}
|
||||
onSelectAll={model.onSelectAll}
|
||||
/>
|
||||
{model.diffMode === "base" ? model.baseBranchPicker : null}
|
||||
{model.selectedDiffStat ? (
|
||||
<DiffStat
|
||||
additions={model.selectedDiffStat.additions}
|
||||
@@ -1904,6 +1909,14 @@ export function ChangesSurface({
|
||||
const changesHeaderModel = useMemo(
|
||||
() =>
|
||||
buildChangesHeaderModel({
|
||||
baseBranchPicker: workspaceId ? (
|
||||
<WorkspaceBaseBranchPicker
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
baseRef={baseRef ?? null}
|
||||
/>
|
||||
) : null,
|
||||
branchName: currentBranchName,
|
||||
committedDescription: committedDiffDescription,
|
||||
compact: isMobile,
|
||||
@@ -1921,6 +1934,7 @@ export function ChangesSurface({
|
||||
workspaceId,
|
||||
}),
|
||||
[
|
||||
baseRef,
|
||||
committedDiffDescription,
|
||||
currentBranchName,
|
||||
cwd,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ToolbarLabelSelectTrigger } from "@/components/ui/toolbar-label-trigger";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "./query-keys";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
baseRef: string | null;
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^refs\/(heads|remotes)\//, "");
|
||||
}
|
||||
|
||||
export function WorkspaceBaseBranchPicker({ serverId, workspaceId, cwd, baseRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceBaseBranch === true,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const anchorRef = useRef<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) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
return client.setWorkspaceBaseBranch({ workspaceId, baseRef: ref });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
toast.show(t("workspace.git.diff.baseBranch.saved"));
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
},
|
||||
});
|
||||
const options = useMemo(
|
||||
() => (branches.data ?? []).map((ref) => ({ id: ref, label: refLabel(ref) })),
|
||||
[branches.data],
|
||||
);
|
||||
const error = mutation.error ?? branches.error;
|
||||
const title = t("workspace.git.diff.baseBranch.title");
|
||||
const label = baseRef ? refLabel(baseRef) : title;
|
||||
const hint = supported
|
||||
? t("workspace.git.diff.baseBranch.hint")
|
||||
: t("workspace.git.diff.baseBranch.updateHost");
|
||||
|
||||
const { reset, mutate, isPending } = mutation;
|
||||
const { refetch, isError } = branches;
|
||||
const handleOpen = useCallback(() => {
|
||||
if (!supported) {
|
||||
toast.error(t("workspace.git.diff.baseBranch.updateHost"));
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
setSearch("");
|
||||
setOpen(true);
|
||||
}, [reset, supported, toast, t]);
|
||||
const handleSelect = useCallback(
|
||||
(ref: string) => {
|
||||
if (!isPending) mutate(ref);
|
||||
},
|
||||
[isPending, mutate],
|
||||
);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
const renderOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
|
||||
({ option, selected, active, onPress }) => (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
description={option.id}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
disabled={isPending}
|
||||
/>
|
||||
),
|
||||
[isPending],
|
||||
);
|
||||
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}>
|
||||
{error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<Button variant="outline" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[t, isPending, error, isError, handleRetry],
|
||||
);
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<ToolbarLabelSelectTrigger
|
||||
testID="workspace-base-branch-trigger"
|
||||
label={label}
|
||||
open={open}
|
||||
disabled={!connected || mutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={hint}
|
||||
onPress={handleOpen}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.text}>{hint}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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={footer}
|
||||
/>
|
||||
</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 },
|
||||
}));
|
||||
@@ -930,6 +930,12 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "الفرع الأساسي",
|
||||
hint: "يُستخدم للتغييرات المثبتة والالتزامات وتحديثات Git وطلبات السحب الجديدة. مشترك بين مساحات العمل التي تستخدم نسخة العمل هذه.",
|
||||
updateHost: "حدّث المضيف لتغيير الفرع الأساسي.",
|
||||
saved: "تم تحديث الفرع الأساسي",
|
||||
},
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
openDiffTab: "فتح علامة تبويب الفرق",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
|
||||
@@ -938,6 +938,12 @@ export const en = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Base branch",
|
||||
hint: "Used for committed changes, commits, Git updates and new pull requests. Shared by workspaces using this checkout.",
|
||||
updateHost: "Update the host to change the base branch.",
|
||||
saved: "Base branch updated",
|
||||
},
|
||||
openChangesTab: "Open Changes tab",
|
||||
openDiffTab: "Open Diff tab",
|
||||
closeChangesTab: "Close Changes tab",
|
||||
|
||||
@@ -963,6 +963,12 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Rama base",
|
||||
hint: "Se usa para cambios confirmados, commits, actualizaciones de Git y nuevas solicitudes de cambios. Se comparte entre los espacios de trabajo de este checkout.",
|
||||
updateHost: "Actualiza el host para cambiar la rama base.",
|
||||
saved: "Rama base actualizada",
|
||||
},
|
||||
openChangesTab: "Abrir la pestaña Cambios",
|
||||
openDiffTab: "Abrir la pestaña Diferencia",
|
||||
closeChangesTab: "Cerrar la pestaña Cambios",
|
||||
|
||||
@@ -956,6 +956,12 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Branche de base",
|
||||
hint: "Utilisée pour les changements validés, les commits, les mises à jour Git et les nouvelles pull requests. Partagée par les espaces de travail de ce checkout.",
|
||||
updateHost: "Mettez à jour l’hôte pour changer la branche de base.",
|
||||
saved: "Branche de base mise à jour",
|
||||
},
|
||||
openChangesTab: "Ouvrir l’onglet Modifications",
|
||||
openDiffTab: "Ouvrir l’onglet Diff",
|
||||
closeChangesTab: "Fermer l’onglet Modifications",
|
||||
|
||||
@@ -943,6 +943,12 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "ベースブランチ",
|
||||
hint: "コミット済みの変更、コミット一覧、Git の更新、新規プルリクエストに使用します。同じチェックアウトのワークスペースで共有されます。",
|
||||
updateHost: "ベースブランチを変更するにはホストを更新してください。",
|
||||
saved: "ベースブランチを更新しました",
|
||||
},
|
||||
openChangesTab: "変更タブを開く",
|
||||
openDiffTab: "差分タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
|
||||
@@ -937,6 +937,12 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "기준 브랜치",
|
||||
hint: "커밋된 변경, 커밋 목록, Git 업데이트 및 새 풀 리퀘스트에 사용됩니다. 같은 체크아웃을 사용하는 워크스페이스가 공유합니다.",
|
||||
updateHost: "기준 브랜치를 변경하려면 호스트를 업데이트하세요.",
|
||||
saved: "기준 브랜치가 업데이트되었습니다",
|
||||
},
|
||||
openChangesTab: "변경사항 탭 열기",
|
||||
openDiffTab: "Diff 탭 열기",
|
||||
closeChangesTab: "변경사항 탭 닫기",
|
||||
|
||||
@@ -953,6 +953,12 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Branch base",
|
||||
hint: "Usada para alterações commitadas, commits, atualizações do Git e novos pull requests. Compartilhada pelos espaços de trabalho deste checkout.",
|
||||
updateHost: "Atualize o host para alterar a branch base.",
|
||||
saved: "Branch base atualizada",
|
||||
},
|
||||
openChangesTab: "Abrir a aba Alterações",
|
||||
openDiffTab: "Abrir a aba Diff",
|
||||
closeChangesTab: "Fechar a aba Alterações",
|
||||
|
||||
@@ -947,6 +947,12 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "Базовая ветка",
|
||||
hint: "Используется для закоммиченных изменений, коммитов, обновлений Git и новых pull request. Общая для рабочих пространств с этой рабочей копией.",
|
||||
updateHost: "Обновите хост, чтобы изменить базовую ветку.",
|
||||
saved: "Базовая ветка обновлена",
|
||||
},
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
openDiffTab: "Открыть вкладку «Дифф»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
|
||||
@@ -922,6 +922,12 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
baseBranch: {
|
||||
title: "基准分支",
|
||||
hint: "用于已提交改动、提交列表、Git 更新和新建 PR。同一 checkout 的工作区共享此设置。",
|
||||
updateHost: "更新 Host 后即可修改基准分支。",
|
||||
saved: "基准分支已更新",
|
||||
},
|
||||
openChangesTab: "打开“更改”标签页",
|
||||
openDiffTab: "打开 Diff 标签页",
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
|
||||
@@ -3546,6 +3546,22 @@ export class DaemonClient {
|
||||
return { pinnedAt: payload.pinnedAt };
|
||||
}
|
||||
|
||||
async setWorkspaceBaseBranch(options: {
|
||||
workspaceId: string;
|
||||
baseRef: string;
|
||||
requestId?: string;
|
||||
}): Promise<{ baseRef: string | null }> {
|
||||
const { requestId, workspaceId, baseRef } = options;
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"workspace.base_branch.set.response">({
|
||||
requestId,
|
||||
message: { type: "workspace.base_branch.set.request", workspaceId, baseRef },
|
||||
});
|
||||
if (!payload.accepted)
|
||||
throw new Error(payload.error ?? "Changing the workspace base branch failed");
|
||||
return { baseRef: payload.baseRef };
|
||||
}
|
||||
|
||||
async inspectWorkspaceRecovery(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
@@ -5098,7 +5114,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async getBranchSuggestions(
|
||||
options: { cwd: string; query?: string; limit?: number },
|
||||
options: { cwd: string; query?: string; limit?: number; refMode?: "exact" },
|
||||
requestId?: string,
|
||||
): Promise<BranchSuggestionsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -5108,6 +5124,7 @@ export class DaemonClient {
|
||||
cwd: options.cwd,
|
||||
query: options.query,
|
||||
limit: options.limit,
|
||||
refMode: options.refMode,
|
||||
},
|
||||
responseType: "branch_suggestions_response",
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -874,6 +878,8 @@ function createWorkspaceHandleFactory(
|
||||
current: () => current,
|
||||
refresh,
|
||||
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
||||
setBaseBranch: (options) =>
|
||||
daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }),
|
||||
archive: async (requestId) => {
|
||||
const result = await daemonClient.archiveWorkspace(id, requestId);
|
||||
if (current) {
|
||||
|
||||
@@ -1037,6 +1037,13 @@ export const WorkspacePinSetRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceBaseBranchSetRequestSchema = z.object({
|
||||
type: z.literal("workspace.base_branch.set.request"),
|
||||
workspaceId: z.string(),
|
||||
baseRef: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceLabelColorSchema = z.enum(WORKSPACE_LABEL_COLORS);
|
||||
export const WorkspaceLabelDefinitionSchema = z.object({
|
||||
name: z.string(),
|
||||
@@ -2193,6 +2200,17 @@ export const WorkspacePinSetResponseSchema = z.object({
|
||||
payload: WorkspacePinSetResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const WorkspaceBaseBranchSetResponseSchema = z.object({
|
||||
type: z.literal("workspace.base_branch.set.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
baseRef: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("recoverable"),
|
||||
@@ -2510,6 +2528,7 @@ export const BranchSuggestionsRequestSchema = z.object({
|
||||
cwd: z.string(),
|
||||
query: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
refMode: z.literal("exact").optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -3324,6 +3343,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
WorkspaceBaseBranchSetRequestSchema,
|
||||
WorkspaceLabelListRequestSchema,
|
||||
WorkspaceLabelAssignmentSetRequestSchema,
|
||||
WorkspaceLabelUpdateRequestSchema,
|
||||
@@ -3706,6 +3726,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
directorySync: z.boolean().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
workspaceLabels: z.boolean().optional(),
|
||||
// COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14.
|
||||
workspaceBaseBranch: z.boolean().optional(),
|
||||
// COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02.
|
||||
workspaceSetupRun: z.boolean().optional(),
|
||||
// COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05.
|
||||
@@ -5399,6 +5421,7 @@ const CheckoutStatusCommonSchema = z.object({
|
||||
cwd: z.string(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
baseRefExact: z.string().nullable().optional(),
|
||||
// The full ref currentBranch tracks, as git resolves `<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
|
||||
@@ -7197,6 +7220,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
WorkspaceBaseBranchSetResponseSchema,
|
||||
WorkspaceRecoveryInspectResponseSchema,
|
||||
WorkspaceRecoveryRestoreResponseSchema,
|
||||
WaitForFinishResponseMessageSchema,
|
||||
@@ -7558,6 +7582,7 @@ export type ProjectIconSetRequest = z.infer<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",
|
||||
|
||||
@@ -3,6 +3,12 @@ import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import {
|
||||
createWorkspaceBaseBranchService,
|
||||
type WorkspaceBaseBranchService,
|
||||
} from "./session/workspace-base-branch/service.js";
|
||||
import type { WorkspaceBaseBranchSetRequest } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
|
||||
@@ -476,6 +482,7 @@ export interface SessionOptions {
|
||||
creationService: Pick<CreationService, "create" | "subscribe">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceBaseBranch?: WorkspaceBaseBranchService;
|
||||
directorySync?: DirectorySyncService;
|
||||
workspaceLabelService?: WorkspaceLabelService;
|
||||
filesystem?: SessionFileSystem;
|
||||
@@ -772,6 +779,7 @@ export class Session {
|
||||
private readonly gitMutation: GitMutationService;
|
||||
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
||||
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
||||
private workspaceBaseBranch: WorkspaceBaseBranchService | undefined;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushNotifications: PushNotifications;
|
||||
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
||||
@@ -955,6 +963,8 @@ export class Session {
|
||||
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
||||
this.workspaceGitService = workspaceGitService;
|
||||
this.workspaceRuntimeStore = workspaceRuntimeStore;
|
||||
this.workspaceBaseBranch = options.workspaceBaseBranch;
|
||||
|
||||
this.gitMutation = createGitMutationService({
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
logger: this.sessionLogger,
|
||||
@@ -3195,6 +3205,8 @@ export class Session {
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "workspace.pin.set.request":
|
||||
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
||||
case "workspace.base_branch.set.request":
|
||||
return this.handleWorkspaceBaseBranchSetRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -4058,6 +4070,39 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceBaseBranchSetRequest(
|
||||
msg: WorkspaceBaseBranchSetRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
const baseRef = await this.workspaceBaseBranch.setBaseBranch(msg);
|
||||
this.emit({
|
||||
type: "workspace.base_branch.set.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
workspaceId: msg.workspaceId,
|
||||
accepted: true,
|
||||
baseRef,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "workspace.base_branch.set.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
workspaceId: msg.workspaceId,
|
||||
accepted: false,
|
||||
baseRef: null,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspacePinSetRequest(
|
||||
workspaceId: string,
|
||||
pinned: boolean,
|
||||
|
||||
@@ -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,84 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadataFile,
|
||||
} from "../../../utils/worktree-metadata.js";
|
||||
import type { WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
interface Dependencies {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
}
|
||||
|
||||
export function createWorkspaceBaseBranchService(deps: Dependencies) {
|
||||
const queues = new Map<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);
|
||||
const previous = queues.get(root);
|
||||
let release!: () => void;
|
||||
const queued = new Promise<void>((done) => {
|
||||
release = done;
|
||||
});
|
||||
queues.set(root, queued);
|
||||
await previous;
|
||||
|
||||
try {
|
||||
const current = await deps.workspaceRegistry.get(input.workspaceId);
|
||||
if (!current || current.archivedAt) throw new Error("Workspace is unavailable");
|
||||
const records = await deps.workspaceRegistry.list();
|
||||
const matching = records.filter(
|
||||
(record) =>
|
||||
record.workspaceId === input.workspaceId ||
|
||||
resolve(record.worktreeRoot ?? record.cwd) === root,
|
||||
);
|
||||
await deps.workspaceGitService.getSnapshot(root);
|
||||
const metadata = readPaseoWorktreeMetadata(root);
|
||||
const baseRef = await setCheckoutBaseBranch({ cwd: root, baseRef: input.baseRef });
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
for (const record of matching) {
|
||||
await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({
|
||||
...latest,
|
||||
baseBranch: baseRef,
|
||||
updatedAt,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (metadata) writePaseoWorktreeMetadataFile(root, metadata);
|
||||
else unlinkSync(getPaseoWorktreeMetadataPath(root));
|
||||
for (const record of matching) {
|
||||
await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({
|
||||
...latest,
|
||||
baseBranch: record.baseBranch,
|
||||
updatedAt: record.updatedAt,
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const cwd of new Set(matching.map((record) => record.cwd))) {
|
||||
await deps.workspaceGitService.getSnapshot(cwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "base-branch-change",
|
||||
});
|
||||
}
|
||||
return baseRef;
|
||||
} finally {
|
||||
release();
|
||||
if (queues.get(root) === queued) queues.delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
return { setBaseBranch };
|
||||
}
|
||||
|
||||
export type WorkspaceBaseBranchService = ReturnType<typeof createWorkspaceBaseBranchService>;
|
||||
+5
@@ -20,6 +20,7 @@ import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.j
|
||||
import { deriveProjectKey } from "../../project-key.js";
|
||||
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../../utils/worktree-metadata.js";
|
||||
|
||||
export interface ResolveOrCreateWorkspaceIdInput {
|
||||
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
||||
@@ -248,6 +249,9 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
): Promise<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.
|
||||
@@ -257,6 +261,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceId: context?.workspaceId ?? generateWorkspaceId(),
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null,
|
||||
title: title?.trim() || null,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -16,6 +16,10 @@ import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js";
|
||||
import { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
createWorkspaceBaseBranchService,
|
||||
type WorkspaceBaseBranchService,
|
||||
} from "./session/workspace-base-branch/service.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import { CreationService } from "./creation/index.js";
|
||||
import { MessageReceipts } from "./message-receipts/index.js";
|
||||
@@ -568,6 +572,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly creationService: CreationService;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceBaseBranch: WorkspaceBaseBranchService;
|
||||
private readonly workspaceLabelService: WorkspaceLabelService | null;
|
||||
private readonly scheduleService: ScheduleService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
@@ -755,6 +760,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.checkoutDiffManager = requiredServices.checkoutDiffManager;
|
||||
this.github = github ?? createGitHubService();
|
||||
this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
|
||||
this.workspaceBaseBranch = createWorkspaceBaseBranchService({
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
this.workspaceAutoName = workspaceAutoName;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
@@ -1660,6 +1669,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationService: this.creationService,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceBaseBranch: this.workspaceBaseBranch,
|
||||
workspaceLabelService: this.workspaceLabelService ?? undefined,
|
||||
directorySync: this.directorySync,
|
||||
scheduleService: this.scheduleService,
|
||||
@@ -2013,6 +2023,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationLifecycle: true,
|
||||
hubAgentRpc: true,
|
||||
backgroundWorkspaces: true,
|
||||
workspaceBaseBranch: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
DaemonClient,
|
||||
@@ -277,6 +278,109 @@ test("renaming a workspace updates every subscribed client", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("changing a checkout base updates subscribed clients and shared workspace recovery records", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["checkout", "-b", "release"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "release.txt"), "release\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "feature.txt"), "feature\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], { cwd: repo });
|
||||
const first = await createLocalWorkspace(repo, "First");
|
||||
const second = await createLocalWorkspace(repo, "Second");
|
||||
const observer = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await observer.connect();
|
||||
const refs: string[] = [];
|
||||
const stop = observer.on("checkout_status_update", (message) => {
|
||||
if (message.payload.cwd === repo && message.payload.baseRefExact)
|
||||
refs.push(message.payload.baseRefExact);
|
||||
});
|
||||
const events = observer.observeEvents(["checkout_status_update"]);
|
||||
try {
|
||||
await events.ready;
|
||||
await observer.fetchWorkspaces({ subscribe: {} });
|
||||
expect(ctx.client.getLastServerInfoMessage()?.features?.workspaceBaseBranch).toBe(true);
|
||||
await ctx.client.getCheckoutStatus(repo);
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(repo, { mode: "base" })).files.map((file) => file.path),
|
||||
).toContain("release.txt");
|
||||
await ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/release" });
|
||||
await expect.poll(() => refs).toContain("refs/heads/release");
|
||||
const diff = await observer.getCheckoutDiff(repo, { mode: "base" });
|
||||
expect(diff.files.map((file) => file.path)).not.toContain("release.txt");
|
||||
expect(diff.files.map((file) => file.path)).toContain("feature.txt");
|
||||
expect((await observer.listCheckoutCommits(repo)).baseRef).toBe("refs/heads/release");
|
||||
const records = JSON.parse(
|
||||
readFileSync(path.join(ctx.daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
);
|
||||
expect(records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ workspaceId: first, baseBranch: "refs/heads/release" }),
|
||||
expect.objectContaining({ workspaceId: second, baseBranch: "refs/heads/release" }),
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
ctx.client.setWorkspaceBaseBranch({ workspaceId: first, baseRef: "refs/heads/missing" }),
|
||||
).rejects.toThrow("Base branch not found");
|
||||
expect((await observer.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release");
|
||||
} finally {
|
||||
stop();
|
||||
await events.release();
|
||||
await observer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selected checkout base survives a daemon restart", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["branch", "release"], { cwd: repo });
|
||||
const id = await createLocalWorkspace(repo, "Restart");
|
||||
await ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/release" });
|
||||
const previous = ctx;
|
||||
const paseoHomeRoot = path.dirname(previous.daemon.paseoHome);
|
||||
await previous.client.close();
|
||||
await previous.daemon.daemon.stop();
|
||||
tempRoots.push(previous.daemon.staticDir);
|
||||
ctx = await createDaemonTestContext({ paseoHomeRoot });
|
||||
expect(
|
||||
(await ctx.client.fetchWorkspaces()).entries.some((workspace) => workspace.id === id),
|
||||
).toBe(true);
|
||||
expect((await ctx.client.getCheckoutStatus(repo)).baseRefExact).toBe("refs/heads/release");
|
||||
});
|
||||
|
||||
test("a managed workspace restores its changed base after archival removes the checkout", async () => {
|
||||
const repo = createGitRepo();
|
||||
execFileSync("git", ["branch", "release"], { cwd: repo });
|
||||
const created = await ctx.client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo, worktreeSlug: "changed-base", baseBranch: "main" },
|
||||
});
|
||||
const workspace = created.workspace;
|
||||
if (!workspace) throw new Error(created.error ?? "Failed to create workspace");
|
||||
await ctx.client.setWorkspaceBaseBranch({
|
||||
workspaceId: workspace.id,
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
const joinedId = await createLocalWorkspace(
|
||||
workspace.workspaceDirectory,
|
||||
"Joined after changing base",
|
||||
);
|
||||
expect((await ctx.client.archiveWorkspace(workspace.id)).error).toBe(null);
|
||||
expect(existsSync(workspace.workspaceDirectory)).toBe(true);
|
||||
expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null);
|
||||
expect(existsSync(workspace.workspaceDirectory)).toBe(false);
|
||||
await ctx.client.restoreWorkspace(joinedId);
|
||||
expect(readPaseoWorktreeMetadata(workspace.workspaceDirectory)?.baseRef).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["worktree", "workspace"] as const)(
|
||||
"%s archive immediately removes the deleted worktree from a warm CLI listing",
|
||||
async (command) => {
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isDirty: boolean | null;
|
||||
baseRef: string | null;
|
||||
baseRefExact?: string | null;
|
||||
aheadBehind: { ahead: number; behind: number } | null;
|
||||
upstreamRef: string | null;
|
||||
aheadOfOrigin: number | null;
|
||||
@@ -296,6 +297,7 @@ export type WorkspaceGitReadOptions =
|
||||
export interface WorkspaceGitBranchSuggestionsOptions {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
refMode?: "exact";
|
||||
}
|
||||
|
||||
export interface WorkspaceGitStashListOptions {
|
||||
@@ -905,7 +907,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const query = options?.query ?? "";
|
||||
const limit = options?.limit;
|
||||
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
|
||||
const key = JSON.stringify([
|
||||
"branch-suggestions",
|
||||
normalizedCwd,
|
||||
query,
|
||||
limit ?? null,
|
||||
options?.refMode,
|
||||
]);
|
||||
return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () =>
|
||||
this.deps.listBranchSuggestions(normalizedCwd, options),
|
||||
);
|
||||
@@ -3521,6 +3529,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
const cwd = target.cwd;
|
||||
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
|
||||
const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null;
|
||||
const baseContext: CheckoutContext = {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
@@ -3528,6 +3537,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
runGitCommand: runRefreshGitCommand,
|
||||
};
|
||||
const facts = await this.loadCheckoutFacts(target, baseContext);
|
||||
if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) {
|
||||
this.invalidateCheckoutDiffCache(cwd, "base");
|
||||
this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base");
|
||||
}
|
||||
const context: CheckoutContext = { ...baseContext, facts };
|
||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
@@ -3560,6 +3573,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
? checkoutStatus.isDirty
|
||||
: (target.latestGit?.isDirty ?? checkoutStatus.isDirty),
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
baseRefExact: checkoutStatus.baseRefExact,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
upstreamRef: checkoutStatus.upstreamRef,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
getCheckoutStatus,
|
||||
checkoutResolvedBranch,
|
||||
listBranchSuggestions,
|
||||
listCheckoutCommits,
|
||||
setCheckoutBaseBranch,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
MergeConflictError,
|
||||
@@ -263,6 +265,136 @@ describe("checkout git utilities", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("changing the checkout base persists an exact ref without changing HEAD or dirty files", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "release"], { cwd: repoDir });
|
||||
commitFile(repoDir, "release.txt", "release\n", "release change");
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature change");
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "dirty\n");
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString();
|
||||
|
||||
expect(await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" })).toBe(
|
||||
"refs/heads/release",
|
||||
);
|
||||
const localDiff = await getCheckoutDiff(repoDir, { mode: "base" });
|
||||
expect(localDiff.diff).toContain("feature.txt");
|
||||
expect(localDiff.diff).not.toContain("release.txt");
|
||||
expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe("refs/heads/release");
|
||||
expect(await getCheckoutSnapshotFacts(repoDir)).toMatchObject({
|
||||
storedBaseRef: "refs/heads/release",
|
||||
});
|
||||
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/remotes/upstream/release" });
|
||||
expect((await getCheckoutDiff(repoDir, { mode: "base" })).diff).toContain("release.txt");
|
||||
expect((await listCheckoutCommits({ cwd: repoDir })).baseRef).toBe(
|
||||
"refs/remotes/upstream/release",
|
||||
);
|
||||
await expect(
|
||||
getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/heads/release" }),
|
||||
).rejects.toThrow("Base ref mismatch");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString()).toBe(head);
|
||||
expect(readTextFile(join(repoDir, "file.txt"))).toBe("dirty\n");
|
||||
expect(await getCurrentBranch(repoDir)).toBe("feature");
|
||||
});
|
||||
|
||||
it("rejects non-branch refs and missing bases without changing the saved base", async () => {
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" });
|
||||
const metadata = readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8");
|
||||
execFileSync("git", ["tag", "release-tag"], { cwd: repoDir });
|
||||
for (const baseRef of [
|
||||
"missing",
|
||||
"HEAD",
|
||||
"refs/tags/release-tag",
|
||||
"main~1",
|
||||
"--help",
|
||||
"refs/heads/main^{commit}",
|
||||
]) {
|
||||
await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef })).rejects.toThrow();
|
||||
expect(readFileSync(getPaseoWorktreeMetadataPath(repoDir), "utf8")).toBe(metadata);
|
||||
}
|
||||
});
|
||||
|
||||
it("changing a managed worktree base preserves its runtime and PR identity", async () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
branchName: "feature/base",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "base",
|
||||
paseoHome,
|
||||
runSetup: false,
|
||||
});
|
||||
const original = readPaseoWorktreeMetadata(worktree.worktreePath);
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
await setCheckoutBaseBranch({ cwd: worktree.worktreePath, baseRef: "release" });
|
||||
expect(readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({
|
||||
...original,
|
||||
baseRefName: "release",
|
||||
baseRef: "refs/heads/release",
|
||||
});
|
||||
expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({
|
||||
storedBaseRef: "refs/heads/release",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists exact local and all remote refs separately for the base picker", async () => {
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir });
|
||||
const suggestions = await listBranchSuggestions(repoDir, {
|
||||
refMode: "exact",
|
||||
query: "release",
|
||||
});
|
||||
expect(suggestions.map((entry) => entry.name).sort()).toEqual([
|
||||
"refs/heads/release",
|
||||
"refs/remotes/origin/release",
|
||||
"refs/remotes/upstream/release",
|
||||
]);
|
||||
expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({
|
||||
hasLocal: true,
|
||||
hasRemote: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges from and into the selected base without changing the repository default branch", async () => {
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature");
|
||||
execFileSync("git", ["checkout", "release"], { cwd: repoDir });
|
||||
commitFile(repoDir, "release.txt", "release\n", "release");
|
||||
execFileSync("git", ["checkout", "feature"], { cwd: repoDir });
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "release" });
|
||||
await mergeFromBase(repoDir);
|
||||
expect(readTextFile(join(repoDir, "release.txt"))).toBe("release\n");
|
||||
const featureHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString();
|
||||
const mainHead = execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString();
|
||||
await mergeToBase(repoDir);
|
||||
expect(execFileSync("git", ["rev-parse", "release"], { cwd: repoDir }).toString()).toBe(
|
||||
featureHead,
|
||||
);
|
||||
expect(execFileSync("git", ["rev-parse", "main"], { cwd: repoDir }).toString()).toBe(mainHead);
|
||||
await expect(mergeFromBase(repoDir, { baseRef: "main" })).rejects.toThrow("Base ref mismatch");
|
||||
});
|
||||
|
||||
it("creates new pull requests targeting the selected base", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execFileSync("git", ["branch", "release"], { cwd: repoDir });
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature");
|
||||
await setCheckoutBaseBranch({ cwd: repoDir, baseRef: "refs/heads/release" });
|
||||
const targets: string[] = [];
|
||||
const adapter = createGitHubServiceForStatus(null);
|
||||
adapter.createPullRequest = async (input) => {
|
||||
targets.push(input.base);
|
||||
return { url: "https://github.com/acme/repo/pull/1", number: 1 };
|
||||
};
|
||||
await createPullRequest(repoDir, { title: "Feature", body: "Feature" }, adapter);
|
||||
expect(targets).toEqual(["release"]);
|
||||
await expect(
|
||||
createPullRequest(repoDir, { title: "Stale target", body: "", base: "main" }, adapter),
|
||||
).rejects.toThrow("Base ref mismatch");
|
||||
});
|
||||
|
||||
it("throws NotGitRepoError for non-git directories", async () => {
|
||||
const nonGitDir = join(tempDir, "not-git");
|
||||
mkdirSync(nonGitDir, { recursive: true });
|
||||
|
||||
@@ -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,33 @@ async function getStoredBaseRefForCwd(
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.storedBaseRef;
|
||||
}
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context });
|
||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return null;
|
||||
}
|
||||
const worktreeRoot = await getWorktreeRoot(cwd, context);
|
||||
return worktreeRoot ? readPaseoWorktreeBaseRef(worktreeRoot) : null;
|
||||
}
|
||||
|
||||
return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot);
|
||||
export async function setCheckoutBaseBranch(input: {
|
||||
cwd: string;
|
||||
baseRef: string;
|
||||
}): Promise<string> {
|
||||
await requireGitRepo(input.cwd);
|
||||
const requested = input.baseRef.trim();
|
||||
const qualified = requested.startsWith("refs/");
|
||||
const candidates = qualified
|
||||
? [requested]
|
||||
: [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`];
|
||||
for (const candidate of candidates) {
|
||||
if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue;
|
||||
const valid = await runGitCommand(["check-ref-format", candidate], {
|
||||
cwd: input.cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue;
|
||||
const worktreeRoot = await getWorktreeRoot(input.cwd);
|
||||
if (!worktreeRoot) throw new NotGitRepoError(input.cwd);
|
||||
setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate });
|
||||
return candidate;
|
||||
}
|
||||
throw new Error(`Base branch not found: ${requested}`);
|
||||
}
|
||||
|
||||
async function getResolvedBaseRefForCwd(
|
||||
@@ -1786,7 +1825,11 @@ async function getAheadBehind(
|
||||
context?: CheckoutContext,
|
||||
): Promise<AheadBehind | null> {
|
||||
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
if (
|
||||
!normalizedBaseRef ||
|
||||
!currentBranch ||
|
||||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const comparisonBaseRef =
|
||||
@@ -2128,7 +2171,8 @@ export async function getCheckoutSnapshotFacts(
|
||||
if (
|
||||
resolvedBaseRef &&
|
||||
inspected.currentBranch &&
|
||||
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch
|
||||
(isQualifiedBranchRef(resolvedBaseRef) ||
|
||||
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch)
|
||||
) {
|
||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
|
||||
() => null,
|
||||
@@ -2373,6 +2417,7 @@ export async function getCheckoutStatus(
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: displayBaseRef ?? baseRef,
|
||||
baseRefExact: baseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2391,6 +2436,7 @@ export async function getCheckoutStatus(
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: displayBaseRef,
|
||||
baseRefExact: baseRef,
|
||||
aheadBehind,
|
||||
upstreamRef,
|
||||
aheadOfOrigin,
|
||||
@@ -2605,7 +2651,10 @@ async function tryResolveCheckoutCommitsBaseRef(
|
||||
return null;
|
||||
}
|
||||
const normalizedBaseRef = branchNameFromRef(baseRef);
|
||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||
if (
|
||||
!normalizedBaseRef ||
|
||||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({
|
||||
localBranchName: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
// baseRefName is the display name; baseRef is the exact ref the worktree was cut from
|
||||
// baseRefName is the display name; baseRef is the checkout's current exact base
|
||||
// ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it
|
||||
// existed only have the name — there are no migrations, so readers fall back.
|
||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||
@@ -249,6 +249,18 @@ export async function writePaseoWorktreeMetadata(
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPaseoWorktreeBaseRef(input: { worktreeRoot: string; baseRef: string }): Promise<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 },
|
||||
|
||||
Reference in New Issue
Block a user