diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts
index 856cabe681a..e1c518a4d80 100644
--- a/apps/desktop/src/main/lib/host-service-coordinator.ts
+++ b/apps/desktop/src/main/lib/host-service-coordinator.ts
@@ -464,6 +464,7 @@ export class HostServiceCoordinator extends EventEmitter {
: path.join(app.getAppPath(), "../../packages/host-service/drizzle"),
DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT),
SUPERSET_HOME_DIR: SUPERSET_HOME_DIR,
+ SUPERSET_LEGACY_WORKTREE_BASE_DIR: row?.worktreeBaseDir ?? "",
SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT),
SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION,
AUTH_TOKEN: config.authToken,
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx
new file mode 100644
index 00000000000..c6c0484c393
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/BranchPrefixControl.tsx
@@ -0,0 +1,112 @@
+import {
+ type BranchPrefixMode,
+ sanitizeSegment,
+} from "@superset/shared/workspace-launch";
+import { Input } from "@superset/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@superset/ui/select";
+import { useEffect, useState } from "react";
+import {
+ BRANCH_PREFIX_MODE_LABELS,
+ BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT,
+} from "../../utils/branch-prefix";
+
+/** Select value standing in for "no override — inherit the host default". */
+const DEFAULT_VALUE = "default";
+
+/** Mode communicated by the control. `null` only appears when `showDefault`. */
+export type BranchPrefixControlMode = BranchPrefixMode | null;
+
+interface BranchPrefixControlProps {
+ mode: BranchPrefixControlMode;
+ customPrefix: string | null;
+ /**
+ * When true, prepends a "Use global default" option whose value is `null`.
+ * Used by the per-project override; the host-wide default omits it.
+ */
+ showDefault?: boolean;
+ disabled?: boolean;
+ onChange: (next: {
+ mode: BranchPrefixControlMode;
+ customPrefix: string | null;
+ }) => void;
+}
+
+/**
+ * Shared select+input for the v2 branch-prefix setting. Used by the host-wide
+ * default (`V2GitSettings`) and the per-project override (`BranchPrefixSection`).
+ * Sanitizes the custom prefix on blur. Empty custom on blur is treated as
+ * "user is still typing": the input clears but no mutation fires.
+ */
+export function BranchPrefixControl({
+ mode,
+ customPrefix,
+ showDefault = false,
+ disabled,
+ onChange,
+}: BranchPrefixControlProps) {
+ const [customPrefixInput, setCustomPrefixInput] = useState(
+ customPrefix ?? "",
+ );
+ useEffect(() => {
+ setCustomPrefixInput(customPrefix ?? "");
+ }, [customPrefix]);
+
+ const selectValue = mode ?? DEFAULT_VALUE;
+
+ const labels = showDefault
+ ? BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT
+ : BRANCH_PREFIX_MODE_LABELS;
+
+ const handleModeChange = (value: string) => {
+ const nextMode: BranchPrefixControlMode =
+ value === DEFAULT_VALUE ? null : (value as BranchPrefixMode);
+ onChange({ mode: nextMode, customPrefix: customPrefixInput || null });
+ };
+
+ const handleCustomPrefixBlur = () => {
+ const sanitized = sanitizeSegment(customPrefixInput);
+ setCustomPrefixInput(sanitized);
+ // Empty sanitized prefix: don't persist `mode=custom, customPrefix=null`
+ // — that lies about user intent. Leave the dropdown alone so they can
+ // type again; an explicit mode change is how they exit `custom`.
+ if (!sanitized) return;
+ onChange({ mode: "custom", customPrefix: sanitized });
+ };
+
+ return (
+
+
+ {selectValue === "custom" && (
+ setCustomPrefixInput(e.target.value)}
+ onBlur={handleCustomPrefixBlur}
+ className="w-[120px]"
+ disabled={disabled}
+ />
+ )}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts
new file mode 100644
index 00000000000..ffe87e68ce9
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/BranchPrefixControl/index.ts
@@ -0,0 +1,2 @@
+export type { BranchPrefixControlMode } from "./BranchPrefixControl";
+export { BranchPrefixControl } from "./BranchPrefixControl";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx
new file mode 100644
index 00000000000..f786ff3888d
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/HostSelect.tsx
@@ -0,0 +1,79 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@superset/ui/select";
+import { HiOutlineComputerDesktop, HiOutlineServer } from "react-icons/hi2";
+
+export interface HostSelectOption {
+ id: string;
+ name: string;
+ isLocal: boolean;
+ isOnline: boolean;
+}
+
+interface HostSelectProps {
+ value: string;
+ options: HostSelectOption[];
+ onValueChange: (id: string) => void;
+ align?: "start" | "end";
+ className?: string;
+}
+
+export function HostSelect({
+ value,
+ options,
+ onValueChange,
+ align = "end",
+ className,
+}: HostSelectProps) {
+ const selected = options.find((option) => option.id === value);
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts
new file mode 100644
index 00000000000..a774c78e893
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/HostSelect/index.ts
@@ -0,0 +1 @@
+export { HostSelect, type HostSelectOption } from "./HostSelect";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/SettingsRow.tsx
similarity index 100%
rename from apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/SettingsRow.tsx
rename to apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/SettingsRow.tsx
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/index.ts
similarity index 100%
rename from apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SettingsRow/index.ts
rename to apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsRow/index.ts
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx
new file mode 100644
index 00000000000..65f197adbd7
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/V2WorktreeLocationPicker.tsx
@@ -0,0 +1,118 @@
+import { Button } from "@superset/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
+import { useState } from "react";
+import { LuFolderOpen, LuRotateCcw } from "react-icons/lu";
+import { RemotePathPicker } from "renderer/components/RemotePathPicker";
+import { electronTrpc } from "renderer/lib/electron-trpc";
+
+interface V2WorktreeLocationPickerProps {
+ currentPath: string | null | undefined;
+ fallbackPath: string | null | undefined;
+ hostUrl: string | null;
+ hostName: string;
+ isRemoteTarget: boolean;
+ disabled?: boolean;
+ browseTitle?: string;
+ browseDescription?: string;
+ onSelect: (path: string) => void | Promise;
+ onReset: () => void | Promise;
+}
+
+export function V2WorktreeLocationPicker({
+ currentPath,
+ fallbackPath,
+ hostUrl,
+ hostName,
+ isRemoteTarget,
+ disabled,
+ browseTitle = "Select worktree location",
+ browseDescription,
+ onSelect,
+ onReset,
+}: V2WorktreeLocationPickerProps) {
+ const selectDirectory = electronTrpc.window.selectDirectory.useMutation();
+ const [remoteBrowseOpen, setRemoteBrowseOpen] = useState(false);
+
+ const displayPath = currentPath ?? fallbackPath ?? "Host unavailable";
+ const isBusy = disabled || selectDirectory.isPending;
+
+ const handleBrowse = async () => {
+ if (isBusy) return;
+ if (isRemoteTarget) {
+ setRemoteBrowseOpen(true);
+ return;
+ }
+ const result = await selectDirectory.mutateAsync({
+ title: browseTitle,
+ defaultPath: currentPath ?? fallbackPath ?? undefined,
+ });
+ if (!result.canceled && result.path) {
+ await onSelect(result.path);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {displayPath}
+
+
+
+
+
+
+ Change location
+
+ {currentPath ? (
+
+
+
+
+ Reset location
+
+ ) : null}
+
+
+ {
+ void onSelect(path);
+ }}
+ />
+ >
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts
new file mode 100644
index 00000000000..a07600c0171
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/index.ts
@@ -0,0 +1,6 @@
+export {
+ useSetV2WorktreeBaseDir,
+ useV2WorktreeLocationSettings,
+ v2WorktreeLocationQueryKey,
+} from "./useV2WorktreeLocationSettings";
+export { V2WorktreeLocationPicker } from "./V2WorktreeLocationPicker";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts
new file mode 100644
index 00000000000..8ccaf32107e
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker/useV2WorktreeLocationSettings.ts
@@ -0,0 +1,44 @@
+import { toast } from "@superset/ui/sonner";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
+
+export function v2WorktreeLocationQueryKey(hostUrl: string | null) {
+ return ["host-settings", "worktree-location", hostUrl] as const;
+}
+
+export function useV2WorktreeLocationSettings(
+ hostUrl: string | null,
+ opts?: { enabled?: boolean },
+) {
+ return useQuery({
+ queryKey: v2WorktreeLocationQueryKey(hostUrl),
+ enabled: Boolean(hostUrl) && (opts?.enabled ?? true),
+ queryFn: async () => {
+ if (!hostUrl) throw new Error("Host unavailable");
+ return getHostServiceClientByUrl(
+ hostUrl,
+ ).settings.worktreeLocation.get.query();
+ },
+ });
+}
+
+export function useSetV2WorktreeBaseDir(hostUrl: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (path: string | null) => {
+ if (!hostUrl) throw new Error("Host unavailable");
+ return getHostServiceClientByUrl(
+ hostUrl,
+ ).settings.worktreeLocation.set.mutate({ path });
+ },
+ onSuccess: (data, path) => {
+ queryClient.setQueryData(v2WorktreeLocationQueryKey(hostUrl), data);
+ toast.success(
+ path ? "Worktree location updated" : "Worktree location reset",
+ );
+ },
+ onError: (err) => {
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ });
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx
index 25467ebdd37..d0c03e34b12 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx
@@ -15,16 +15,13 @@ import {
import { Switch } from "@superset/ui/switch";
import { useEffect, useState } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
-import {
- useDefaultWorktreePath,
- WorktreeLocationPicker,
-} from "../../../components/WorktreeLocationPicker";
import { BRANCH_PREFIX_MODE_LABELS } from "../../../utils/branch-prefix";
import {
isItemVisible,
SETTING_ITEM_ID,
type SettingItemId,
} from "../../../utils/settings-search";
+import { UserWorktreeLocationSection } from "./components/UserWorktreeLocationSection";
interface GitSettingsProps {
visibleItems?: SettingItemId[] | null;
@@ -110,30 +107,6 @@ export function GitSettings({ visibleItems }: GitSettingsProps) {
});
};
- const { data: worktreeBaseDir, isLoading: isWorktreeBaseDirLoading } =
- electronTrpc.settings.getWorktreeBaseDir.useQuery();
- const setWorktreeBaseDir =
- electronTrpc.settings.setWorktreeBaseDir.useMutation({
- onMutate: async ({ path }) => {
- await utils.settings.getWorktreeBaseDir.cancel();
- const previous = utils.settings.getWorktreeBaseDir.getData();
- utils.settings.getWorktreeBaseDir.setData(undefined, path);
- return { previous };
- },
- onError: (_err, _vars, context) => {
- if (context?.previous !== undefined) {
- utils.settings.getWorktreeBaseDir.setData(
- undefined,
- context.previous,
- );
- }
- },
- onSettled: () => {
- utils.settings.getWorktreeBaseDir.invalidate();
- },
- });
- const defaultWorktreePath = useDefaultWorktreePath();
-
const previewPrefix =
resolveBranchPrefix({
mode: branchPrefix?.mode ?? "none",
@@ -231,24 +204,7 @@ export function GitSettings({ visibleItems }: GitSettingsProps) {
)}
- {showWorktreeLocation && (
-
-
-
- Base directory for new worktrees
-
-
setWorktreeBaseDir.mutate({ path })}
- onReset={() => setWorktreeBaseDir.mutate({ path: null })}
- />
-
- )}
+ {showWorktreeLocation && }
);
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx
new file mode 100644
index 00000000000..0f20d0be6f0
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/UserWorktreeLocationSection.tsx
@@ -0,0 +1,172 @@
+import { Label } from "@superset/ui/label";
+import { useMemo, useState } from "react";
+import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
+import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled";
+import { electronTrpc } from "renderer/lib/electron-trpc";
+import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions";
+import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
+import {
+ HostSelect,
+ type HostSelectOption,
+} from "renderer/routes/_authenticated/settings/components/HostSelect";
+import {
+ useSetV2WorktreeBaseDir,
+ useV2WorktreeLocationSettings,
+ V2WorktreeLocationPicker,
+} from "renderer/routes/_authenticated/settings/components/V2WorktreeLocationPicker";
+import {
+ useDefaultWorktreePath,
+ WorktreeLocationPicker,
+} from "renderer/routes/_authenticated/settings/components/WorktreeLocationPicker";
+
+export function UserWorktreeLocationSection() {
+ const isV2CloudEnabled = useIsV2CloudEnabled();
+ return isV2CloudEnabled ? : ;
+}
+
+function V1Body() {
+ const utils = electronTrpc.useUtils();
+ const defaultWorktreePath = useDefaultWorktreePath();
+
+ const { data: worktreeBaseDir, isLoading } =
+ electronTrpc.settings.getWorktreeBaseDir.useQuery();
+ const setWorktreeBaseDir =
+ electronTrpc.settings.setWorktreeBaseDir.useMutation({
+ onMutate: async ({ path }) => {
+ await utils.settings.getWorktreeBaseDir.cancel();
+ const previous = utils.settings.getWorktreeBaseDir.getData();
+ utils.settings.getWorktreeBaseDir.setData(undefined, path);
+ return { previous };
+ },
+ onError: (_err, _vars, context) => {
+ if (context?.previous !== undefined) {
+ utils.settings.getWorktreeBaseDir.setData(
+ undefined,
+ context.previous,
+ );
+ }
+ },
+ onSettled: () => {
+ utils.settings.getWorktreeBaseDir.invalidate();
+ },
+ });
+
+ return (
+
+
+
+ Base directory for new worktrees
+
+
setWorktreeBaseDir.mutate({ path })}
+ onReset={() => setWorktreeBaseDir.mutate({ path: null })}
+ />
+
+ );
+}
+
+function V2Body() {
+ const { machineId } = useLocalHostService();
+ const { currentDeviceName, localHostId, otherHosts } =
+ useWorkspaceHostOptions();
+ const defaultWorktreePath = useDefaultWorktreePath();
+
+ const hostOptions = useMemo(() => {
+ const opts: HostSelectOption[] = [];
+ if (localHostId) {
+ opts.push({
+ id: localHostId,
+ name: currentDeviceName ?? "This device",
+ isLocal: true,
+ isOnline: true,
+ });
+ }
+ for (const host of otherHosts) {
+ opts.push({
+ id: host.id,
+ name: host.name,
+ isLocal: false,
+ isOnline: host.isOnline,
+ });
+ }
+ return opts;
+ }, [currentDeviceName, localHostId, otherHosts]);
+
+ const [selectedHostId, setSelectedHostId] = useState(
+ () => localHostId ?? machineId ?? null,
+ );
+ const effectiveHostId =
+ selectedHostId && hostOptions.some((o) => o.id === selectedHostId)
+ ? selectedHostId
+ : (hostOptions[0]?.id ?? null);
+
+ const targetHostUrl = useHostUrl(effectiveHostId);
+ const selectedHost =
+ hostOptions.find((o) => o.id === effectiveHostId) ?? null;
+ const isLocal = selectedHost?.isLocal ?? true;
+ const isOnline = selectedHost?.isOnline ?? false;
+ const hasMultipleHosts = hostOptions.length > 1;
+
+ const settingsQuery = useV2WorktreeLocationSettings(targetHostUrl, {
+ enabled: isOnline,
+ });
+ const setLocation = useSetV2WorktreeBaseDir(targetHostUrl);
+
+ const disabled =
+ !targetHostUrl ||
+ !isOnline ||
+ settingsQuery.isLoading ||
+ setLocation.isPending;
+
+ return (
+
+
+
+
+
+ {hasMultipleHosts
+ ? `Base directory for new worktrees on ${
+ selectedHost?.isLocal
+ ? "this device"
+ : (selectedHost?.name ?? "this device")
+ }`
+ : "Base directory for new worktrees"}
+
+
+ {hasMultipleHosts && effectiveHostId ? (
+
+ ) : null}
+
+
setLocation.mutate(path)}
+ onReset={() => setLocation.mutate(null)}
+ />
+ {hasMultipleHosts && !isOnline ? (
+
+ {selectedHost?.name ?? "This device"} is offline.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts
new file mode 100644
index 00000000000..6b2fab60f15
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/components/UserWorktreeLocationSection/index.ts
@@ -0,0 +1 @@
+export { UserWorktreeLocationSection } from "./UserWorktreeLocationSection";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx
new file mode 100644
index 00000000000..ba015bd730d
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx
@@ -0,0 +1,235 @@
+import {
+ type BranchPrefixMode,
+ resolveBranchPrefix,
+} from "@superset/shared/workspace-launch";
+import { toast } from "@superset/ui/sonner";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import { useMemo } from "react";
+import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
+import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
+import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable";
+import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions";
+import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
+import { BranchPrefixControl } from "../../../components/BranchPrefixControl";
+import {
+ HostSelect,
+ type HostSelectOption,
+} from "../../../components/HostSelect";
+import { SettingsRow } from "../../../components/SettingsRow";
+import {
+ useSetV2WorktreeBaseDir,
+ useV2WorktreeLocationSettings,
+ V2WorktreeLocationPicker,
+} from "../../../components/V2WorktreeLocationPicker";
+import { useDefaultWorktreePath } from "../../../components/WorktreeLocationPicker";
+
+interface V2GitSettingsProps {
+ hostId: string | null;
+}
+
+/**
+ * v2 Git settings — host-wide branch-prefix default for whichever device the
+ * picker has selected. Per-host setting; the dropdown only appears when the
+ * user has 2+ devices in this org.
+ */
+export function V2GitSettings({ hostId }: V2GitSettingsProps) {
+ const navigate = useNavigate();
+ const hostService = useLocalHostService();
+ const { machineId } = hostService;
+ const { currentDeviceName, localHostId, otherHosts } =
+ useWorkspaceHostOptions();
+ const targetHostUrl = useHostUrl(hostId);
+ const targetHostId = hostId ?? machineId;
+ const queryClient = useQueryClient();
+
+ const hostOptions = useMemo(() => {
+ const options: HostSelectOption[] = [];
+ if (localHostId) {
+ options.push({
+ id: localHostId,
+ name: currentDeviceName ?? "This device",
+ isLocal: true,
+ isOnline: true,
+ });
+ }
+ for (const host of otherHosts) {
+ options.push({
+ id: host.id,
+ name: host.name,
+ isLocal: false,
+ isOnline: host.isOnline,
+ });
+ }
+ if (targetHostId && !options.some((o) => o.id === targetHostId)) {
+ options.push({
+ id: targetHostId,
+ name: targetHostId === machineId ? "This device" : targetHostId,
+ isLocal: targetHostId === machineId,
+ isOnline: targetHostId === machineId,
+ });
+ }
+ return options;
+ }, [currentDeviceName, localHostId, machineId, otherHosts, targetHostId]);
+
+ const selectedHost = useMemo(
+ () => hostOptions.find((o) => o.id === targetHostId) ?? null,
+ [hostOptions, targetHostId],
+ );
+ const hasMultipleHosts = hostOptions.length > 1;
+ const isRemoteTarget = Boolean(selectedHost && !selectedHost.isLocal);
+ const isHostOnline = selectedHost?.isOnline ?? true;
+ const selectedHostName = selectedHost?.isLocal
+ ? "this device"
+ : (selectedHost?.name ?? "this device");
+
+ const worktreeQuery = useV2WorktreeLocationSettings(targetHostUrl, {
+ enabled: isHostOnline,
+ });
+ const setWorktreeBaseDir = useSetV2WorktreeBaseDir(targetHostUrl);
+ const defaultWorktreePath = useDefaultWorktreePath();
+
+ const branchPrefixQuery = useQuery({
+ queryKey: ["host-branch-prefix", targetHostUrl] as const,
+ enabled: !!targetHostUrl && isHostOnline,
+ queryFn: () => {
+ if (!targetHostUrl) throw new Error("Host service unavailable");
+ return getHostServiceClientByUrl(
+ targetHostUrl,
+ ).settings.branchPrefix.get.query();
+ },
+ });
+
+ const gitInfoQuery = useQuery({
+ queryKey: ["host-git-info", targetHostUrl] as const,
+ enabled: !!targetHostUrl && isHostOnline,
+ staleTime: 5 * 60 * 1000,
+ queryFn: () => {
+ if (!targetHostUrl) throw new Error("Host service unavailable");
+ return getHostServiceClientByUrl(
+ targetHostUrl,
+ ).settings.branchPrefix.gitInfo.query();
+ },
+ });
+
+ const mode: BranchPrefixMode = branchPrefixQuery.data?.mode ?? "none";
+ const customPrefix = branchPrefixQuery.data?.customPrefix ?? null;
+
+ const setMutation = useMutation({
+ mutationFn: (vars: {
+ mode: BranchPrefixMode;
+ customPrefix: string | null;
+ }) => {
+ if (!targetHostUrl) {
+ throw new Error(
+ getHostServiceUnavailableMessage(hostService, {
+ action: "update the branch prefix",
+ }),
+ );
+ }
+ return getHostServiceClientByUrl(
+ targetHostUrl,
+ ).settings.branchPrefix.set.mutate(vars);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: ["host-branch-prefix", targetHostUrl],
+ });
+ },
+ onError: (err) =>
+ toast.error(
+ err instanceof Error ? err.message : "Failed to update branch prefix",
+ ),
+ });
+
+ const previewPrefix =
+ resolveBranchPrefix({
+ mode,
+ customPrefix,
+ authorPrefix: gitInfoQuery.data?.authorName,
+ githubUsername: gitInfoQuery.data?.githubUsername,
+ }) ||
+ (mode === "author" ? "author-name" : mode === "github" ? "username" : null);
+
+ const controlsDisabled =
+ !targetHostUrl ||
+ !isHostOnline ||
+ branchPrefixQuery.isLoading ||
+ setMutation.isPending;
+
+ return (
+
+
+
+
+
+ Group new branches under a folder.{" "}
+
+ {previewPrefix ? `${previewPrefix}/branch-name` : "branch-name"}
+
+ >
+ }
+ >
+
+ setMutation.mutate({
+ mode: next.mode ?? "none",
+ customPrefix: next.customPrefix,
+ })
+ }
+ />
+
+
+ setWorktreeBaseDir.mutate(path)}
+ onReset={() => setWorktreeBaseDir.mutate(null)}
+ />
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts
new file mode 100644
index 00000000000..5443e5f9119
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/index.ts
@@ -0,0 +1 @@
+export { V2GitSettings } from "./V2GitSettings";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx
index 8e9bbc0e3fd..5881c009e2c 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx
@@ -4,14 +4,19 @@ import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled";
import { useSettingsSearchQuery } from "renderer/stores/settings-state";
import { getVisibleItemsForSection } from "../utils/settings-search";
import { GitSettings } from "./components/GitSettings";
+import { V2GitSettings } from "./components/V2GitSettings";
export const Route = createFileRoute("/_authenticated/settings/git/")({
component: GitSettingsPage,
+ validateSearch: (search: Record): { hostId?: string } => ({
+ hostId: typeof search.hostId === "string" ? search.hostId : undefined,
+ }),
});
function GitSettingsPage() {
const searchQuery = useSettingsSearchQuery();
const isV2CloudEnabled = useIsV2CloudEnabled();
+ const { hostId } = Route.useSearch();
const visibleItems = useMemo(
() =>
@@ -23,5 +28,9 @@ function GitSettingsPage() {
[searchQuery, isV2CloudEnabled],
);
+ if (isV2CloudEnabled) {
+ return ;
+ }
+
return ;
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
index 178b02be496..f71b96c0d1e 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx
@@ -2,17 +2,20 @@ import { toast } from "@superset/ui/sonner";
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useMemo } from "react";
+import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
import { authClient } from "renderer/lib/auth-client";
import {
type PersistableTransaction,
useOptimisticCollectionActions,
} from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
+import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import type { CandidateRow } from "./components/AddMemberDropdown";
import { AddMemberDropdown } from "./components/AddMemberDropdown";
import { HostHeader } from "./components/HostHeader";
import type { MemberRowData } from "./components/MembersTable";
import { MembersTable } from "./components/MembersTable";
+import { WorktreeLocationSection } from "./components/WorktreeLocationSection";
function notifyOnPersist(
tx: PersistableTransaction | null,
@@ -33,6 +36,8 @@ export function HostSettings({ hostId }: HostSettingsProps) {
const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id ?? null;
const actions = useOptimisticCollectionActions();
+ const { machineId } = useLocalHostService();
+ const hostUrl = useHostUrl(hostId);
const { data: hostRows = [], isReady: hostReady } = useLiveQuery(
(q) =>
@@ -119,6 +124,7 @@ export function HostSettings({ hostId }: HostSettingsProps) {
hostUserRows.find((r) => r.userId === currentUserId)?.role === "owner"
);
}, [hostUserRows, currentUserId]);
+ const isRemoteTarget = Boolean(machineId && hostId !== machineId);
if (!host) {
if (!hostReady) return null;
@@ -163,28 +169,38 @@ export function HostSettings({ hostId }: HostSettingsProps) {
canRename={isOwner}
/>
-
-
-
-
Members
- {!isOwner && (
-
- Only owners can change membership.
-
+
+
+
+
+
+
+
Members
+ {!isOwner && (
+
+ Only owners can change membership.
+
+ )}
+
+ {isOwner && (
+
)}
- {isOwner && (
-
- )}
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx
new file mode 100644
index 00000000000..eb13752aeab
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx
@@ -0,0 +1,60 @@
+import {
+ useSetV2WorktreeBaseDir,
+ useV2WorktreeLocationSettings,
+ V2WorktreeLocationPicker,
+} from "../../../../../../components/V2WorktreeLocationPicker";
+
+interface WorktreeLocationSectionProps {
+ hostUrl: string | null;
+ hostName: string;
+ isRemoteTarget: boolean;
+ isOnline: boolean;
+ canEdit: boolean;
+}
+
+export function WorktreeLocationSection({
+ hostUrl,
+ hostName,
+ isRemoteTarget,
+ isOnline,
+ canEdit,
+}: WorktreeLocationSectionProps) {
+ const settingsQuery = useV2WorktreeLocationSettings(hostUrl, {
+ enabled: isOnline,
+ });
+ const setLocation = useSetV2WorktreeBaseDir(hostUrl);
+
+ const disabled =
+ !canEdit ||
+ !isOnline ||
+ !hostUrl ||
+ settingsQuery.isLoading ||
+ setLocation.isPending;
+
+ return (
+
+
+
Worktrees
+
+ Default location for new worktree workspaces on this host.
+
+
+ setLocation.mutate(path)}
+ onReset={() => setLocation.mutate(null)}
+ />
+ {!canEdit ? (
+
+ Only host owners can change this location.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts
new file mode 100644
index 00000000000..8d38884a55f
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/WorktreeLocationSection/index.ts
@@ -0,0 +1 @@
+export { WorktreeLocationSection } from "./WorktreeLocationSection";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts
index 5b860fb8946..39601d6a9cf 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from "bun:test";
import {
+ getAllowedSectionsForVariant,
+ getVisibleItemsForSection,
SETTING_ITEM_ID,
type SettingsItem,
searchSettings,
@@ -62,4 +64,15 @@ describe("settings search - font settings", () => {
expect(editorFont?.section).toBe("appearance");
expect(terminalFont?.section).toBe("appearance");
});
+
+ it("keeps the Git tab visible in v2 for the user worktree location", () => {
+ expect(getAllowedSectionsForVariant(true).has("git")).toBe(true);
+ expect(
+ getVisibleItemsForSection({
+ section: "git",
+ searchQuery: "",
+ isV2: true,
+ }),
+ ).toEqual([SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]);
+ });
});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts
index 6fd9c328ffb..d7f25fab62f 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts
@@ -84,6 +84,7 @@ export const SETTING_ITEM_ID = {
HOST_MEMBERS: "host-members",
HOST_INVITE_MEMBER: "host-invite-member",
HOST_MEMBER_ROLE: "host-member-role",
+ HOST_WORKTREE_LOCATION: "host-worktree-location",
} as const;
export type SettingItemId =
@@ -137,9 +138,10 @@ export const SETTING_ITEM_VARIANT: Record = {
[SETTING_ITEM_ID.BEHAVIOR_RESOURCE_MONITOR]: "shared",
[SETTING_ITEM_ID.BEHAVIOR_OPEN_LINKS_IN_APP]: "v1",
- [SETTING_ITEM_ID.GIT_BRANCH_PREFIX]: "v1",
+ // Branch prefix exists in both UIs — v1 `GitSettings`, v2 `V2GitSettings`.
+ [SETTING_ITEM_ID.GIT_BRANCH_PREFIX]: "shared",
[SETTING_ITEM_ID.GIT_DELETE_LOCAL_BRANCH]: "v1",
- [SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "v1",
+ [SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "shared",
[SETTING_ITEM_ID.AGENTS_ENABLED]: "shared",
[SETTING_ITEM_ID.AGENTS_COMMANDS]: "shared",
@@ -173,7 +175,7 @@ export const SETTING_ITEM_VARIANT: Record = {
[SETTING_ITEM_ID.PROJECT_PATH]: "shared",
[SETTING_ITEM_ID.PROJECT_SCRIPTS]: "v1",
[SETTING_ITEM_ID.PROJECT_BRANCH_PREFIX]: "v1",
- [SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION]: "v1",
+ [SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION]: "shared",
[SETTING_ITEM_ID.PROJECT_IMPORT_WORKTREES]: "v1",
[SETTING_ITEM_ID.PROJECT_ENV_VARS]: "v2",
@@ -191,6 +193,7 @@ export const SETTING_ITEM_VARIANT: Record = {
[SETTING_ITEM_ID.HOST_MEMBERS]: "shared",
[SETTING_ITEM_ID.HOST_INVITE_MEMBER]: "shared",
[SETTING_ITEM_ID.HOST_MEMBER_ROLE]: "shared",
+ [SETTING_ITEM_ID.HOST_WORKTREE_LOCATION]: "v2",
};
export function isItemAllowedForVariant(
@@ -590,7 +593,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [
id: SETTING_ITEM_ID.GIT_WORKTREE_LOCATION,
section: "git",
title: "Worktree location",
- description: "Base directory where new worktrees are created on disk",
+ description: "User-level base directory where new worktrees are created",
keywords: [
"git",
"worktree",
@@ -1087,7 +1090,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [
id: SETTING_ITEM_ID.PROJECT_WORKTREE_LOCATION,
section: "project",
title: "Worktree Location",
- description: "Override the global worktree directory for this project",
+ description: "Override the host worktree directory for this project",
keywords: [
"project",
"worktree",
@@ -1290,6 +1293,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [
"device",
],
},
+ {
+ id: SETTING_ITEM_ID.HOST_WORKTREE_LOCATION,
+ section: "hosts",
+ title: "Worktree location",
+ description: "Default location for new worktree workspaces on this host",
+ keywords: [
+ "host",
+ "hosts",
+ "worktree",
+ "worktrees",
+ "location",
+ "directory",
+ "path",
+ "folder",
+ "storage",
+ "default",
+ ],
+ },
{
id: SETTING_ITEM_ID.HOST_INVITE_MEMBER,
section: "hosts",
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
index d236f20265d..fecb6da9a1b 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx
@@ -1,41 +1,32 @@
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@superset/ui/select";
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
-import { HiOutlineComputerDesktop, HiOutlineServer } from "react-icons/hi2";
import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
+import {
+ HostSelect,
+ type HostSelectOption,
+} from "../../../../components/HostSelect";
+import { SettingsRow } from "../../../../components/SettingsRow";
+import { BranchPrefixSection } from "./components/BranchPrefixSection";
import { DeleteProjectSection } from "./components/DeleteProjectSection";
import { IconUploadField } from "./components/IconUploadField";
import { NameSection } from "./components/NameSection";
import { ProjectLocationSection } from "./components/ProjectLocationSection";
import { RepositorySection } from "./components/RepositorySection";
-import { SettingsRow } from "./components/SettingsRow";
import { V2ScriptsEditor } from "./components/V2ScriptsEditor";
+import { WorktreeLocationSection } from "./components/WorktreeLocationSection";
interface V2ProjectSettingsProps {
projectId: string;
hostId: string | null;
}
-interface ProjectSettingsHostOption {
- id: string;
- name: string;
- isLocal: boolean;
- isOnline: boolean;
-}
-
export function V2ProjectSettings({
projectId,
hostId,
@@ -57,8 +48,8 @@ export function V2ProjectSettings({
[collections, projectId],
);
- const hostOptions = useMemo(() => {
- const options: ProjectSettingsHostOption[] = [];
+ const hostOptions = useMemo(() => {
+ const options: HostSelectOption[] = [];
if (localHostId) {
options.push({
id: localHostId,
@@ -132,8 +123,9 @@ export function V2ProjectSettings({
{project.name}
{hasMultipleHosts && targetHostId ? (
-
+ />
) : null}
@@ -203,6 +149,20 @@ export function V2ProjectSettings({
currentRepoCloneUrl={project.repoCloneUrl}
/>
+ {targetHostUrl && hostProject && (
+
+ refetchHostProject()}
+ />
+
+ )}
@@ -218,6 +178,21 @@ export function V2ProjectSettings({
onChanged={() => refetchHostProject()}
/>
+
+ refetchHostProject()}
+ />
+
{targetHostUrl && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx
new file mode 100644
index 00000000000..bee986a0cb9
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/BranchPrefixSection.tsx
@@ -0,0 +1,48 @@
+import type { BranchPrefixMode } from "@superset/shared/workspace-launch";
+import { toast } from "@superset/ui/sonner";
+import { useMutation } from "@tanstack/react-query";
+import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
+import { BranchPrefixControl } from "renderer/routes/_authenticated/settings/components/BranchPrefixControl";
+
+interface BranchPrefixSectionProps {
+ projectId: string;
+ hostUrl: string;
+ /** Current override; `null` means the project inherits the host default. */
+ mode: BranchPrefixMode | null;
+ customPrefix: string | null;
+ onChanged: () => void;
+}
+
+export function BranchPrefixSection({
+ projectId,
+ hostUrl,
+ mode,
+ customPrefix,
+ onChanged,
+}: BranchPrefixSectionProps) {
+ const setMutation = useMutation({
+ mutationFn: (vars: {
+ mode: BranchPrefixMode | null;
+ customPrefix: string | null;
+ }) =>
+ getHostServiceClientByUrl(hostUrl).project.setBranchPrefix.mutate({
+ projectId,
+ ...vars,
+ }),
+ onSuccess: () => onChanged(),
+ onError: (err) =>
+ toast.error(
+ err instanceof Error ? err.message : "Failed to update branch prefix",
+ ),
+ });
+
+ return (
+
setMutation.mutate(next)}
+ />
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts
new file mode 100644
index 00000000000..7a69965a79d
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection/index.ts
@@ -0,0 +1 @@
+export { BranchPrefixSection } from "./BranchPrefixSection";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx
new file mode 100644
index 00000000000..993c5ca9b98
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/WorktreeLocationSection.tsx
@@ -0,0 +1,78 @@
+import { toast } from "@superset/ui/sonner";
+import { useMutation } from "@tanstack/react-query";
+import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
+import {
+ useV2WorktreeLocationSettings,
+ V2WorktreeLocationPicker,
+} from "../../../../../../components/V2WorktreeLocationPicker";
+
+interface WorktreeLocationSectionProps {
+ projectId: string;
+ currentPath: string | null;
+ hostUrl: string | null;
+ hostName: string;
+ isRemoteTarget: boolean;
+ isHostOnline: boolean;
+ isProjectSetup: boolean;
+ onChanged?: () => void;
+}
+
+export function WorktreeLocationSection({
+ projectId,
+ currentPath,
+ hostUrl,
+ hostName,
+ isRemoteTarget,
+ isHostOnline,
+ isProjectSetup,
+ onChanged,
+}: WorktreeLocationSectionProps) {
+ const hostSettingsQuery = useV2WorktreeLocationSettings(hostUrl, {
+ enabled: isHostOnline,
+ });
+
+ const setLocation = useMutation({
+ mutationFn: async (path: string | null) => {
+ if (!hostUrl) throw new Error("Host unavailable");
+ return getHostServiceClientByUrl(
+ hostUrl,
+ ).project.setWorktreeBaseDir.mutate({ projectId, path });
+ },
+ onSuccess: (_data, path) => {
+ toast.success(
+ path
+ ? "Project worktree location updated"
+ : "Project worktree location reset",
+ );
+ onChanged?.();
+ },
+ onError: (err) => {
+ toast.error(err instanceof Error ? err.message : String(err));
+ },
+ });
+
+ return (
+ setLocation.mutate(path)}
+ onReset={() => setLocation.mutate(null)}
+ />
+ );
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts
new file mode 100644
index 00000000000..8d38884a55f
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection/index.ts
@@ -0,0 +1 @@
+export { WorktreeLocationSection } from "./WorktreeLocationSection";
diff --git a/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql b/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql
new file mode 100644
index 00000000000..70121dee4c2
--- /dev/null
+++ b/packages/host-service/drizzle/0005_host_settings_and_project_overrides.sql
@@ -0,0 +1,10 @@
+CREATE TABLE `host_settings` (
+ `id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
+ `worktree_base_dir` text,
+ `branch_prefix_mode` text,
+ `branch_prefix_custom` text
+);
+--> statement-breakpoint
+ALTER TABLE `projects` ADD `worktree_base_dir` text;--> statement-breakpoint
+ALTER TABLE `projects` ADD `branch_prefix_mode` text;--> statement-breakpoint
+ALTER TABLE `projects` ADD `branch_prefix_custom` text;
\ No newline at end of file
diff --git a/packages/host-service/drizzle/meta/0005_snapshot.json b/packages/host-service/drizzle/meta/0005_snapshot.json
new file mode 100644
index 00000000000..700d32b888a
--- /dev/null
+++ b/packages/host-service/drizzle/meta/0005_snapshot.json
@@ -0,0 +1,651 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "0c6f294e-13f5-4aa7-86cd-2997c78d76cf",
+ "prevId": "db84ad81-bfc3-48e0-8393-7e8d004c7ffb",
+ "tables": {
+ "host_agent_configs": {
+ "name": "host_agent_configs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "preset_id": {
+ "name": "preset_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "args_json": {
+ "name": "args_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "prompt_transport": {
+ "name": "prompt_transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "prompt_args_json": {
+ "name": "prompt_args_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "env_json": {
+ "name": "env_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "host_agent_configs_display_order_idx": {
+ "name": "host_agent_configs_display_order_idx",
+ "columns": [
+ "display_order"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "host_settings": {
+ "name": "host_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "worktree_base_dir": {
+ "name": "worktree_base_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch_prefix_mode": {
+ "name": "branch_prefix_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch_prefix_custom": {
+ "name": "branch_prefix_custom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repo_path": {
+ "name": "repo_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repo_provider": {
+ "name": "repo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repo_owner": {
+ "name": "repo_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repo_name": {
+ "name": "repo_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "repo_url": {
+ "name": "repo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_name": {
+ "name": "remote_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "worktree_base_dir": {
+ "name": "worktree_base_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch_prefix_mode": {
+ "name": "branch_prefix_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch_prefix_custom": {
+ "name": "branch_prefix_custom",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_repo_path_idx": {
+ "name": "projects_repo_path_idx",
+ "columns": [
+ "repo_path"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pull_requests": {
+ "name": "pull_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repo_provider": {
+ "name": "repo_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repo_owner": {
+ "name": "repo_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "repo_name": {
+ "name": "repo_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pr_number": {
+ "name": "pr_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_draft": {
+ "name": "is_draft",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "head_branch": {
+ "name": "head_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "head_sha": {
+ "name": "head_sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "review_decision": {
+ "name": "review_decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "checks_status": {
+ "name": "checks_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "checks_json": {
+ "name": "checks_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "last_fetched_at": {
+ "name": "last_fetched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pull_requests_project_id_idx": {
+ "name": "pull_requests_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "pull_requests_repo_branch_idx": {
+ "name": "pull_requests_repo_branch_idx",
+ "columns": [
+ "repo_provider",
+ "repo_owner",
+ "repo_name",
+ "head_branch"
+ ],
+ "isUnique": false
+ },
+ "pull_requests_repo_pr_unique": {
+ "name": "pull_requests_repo_pr_unique",
+ "columns": [
+ "repo_provider",
+ "repo_owner",
+ "repo_name",
+ "pr_number"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "pull_requests_project_id_projects_id_fk": {
+ "name": "pull_requests_project_id_projects_id_fk",
+ "tableFrom": "pull_requests",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "terminal_sessions": {
+ "name": "terminal_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "origin_workspace_id": {
+ "name": "origin_workspace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_attached_at": {
+ "name": "last_attached_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "terminal_sessions_origin_workspace_id_idx": {
+ "name": "terminal_sessions_origin_workspace_id_idx",
+ "columns": [
+ "origin_workspace_id"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_status_idx": {
+ "name": "terminal_sessions_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "terminal_sessions_origin_workspace_id_workspaces_id_fk": {
+ "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "workspaces",
+ "columnsFrom": [
+ "origin_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "workspaces": {
+ "name": "workspaces",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "worktree_path": {
+ "name": "worktree_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "branch": {
+ "name": "branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "head_sha": {
+ "name": "head_sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "upstream_owner": {
+ "name": "upstream_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "upstream_repo": {
+ "name": "upstream_repo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "upstream_branch": {
+ "name": "upstream_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pull_request_id": {
+ "name": "pull_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "workspaces_project_id_idx": {
+ "name": "workspaces_project_id_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "workspaces_upstream_ref_idx": {
+ "name": "workspaces_upstream_ref_idx",
+ "columns": [
+ "upstream_owner",
+ "upstream_repo",
+ "upstream_branch"
+ ],
+ "isUnique": false
+ },
+ "workspaces_pull_request_id_idx": {
+ "name": "workspaces_pull_request_id_idx",
+ "columns": [
+ "pull_request_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "workspaces_project_id_projects_id_fk": {
+ "name": "workspaces_project_id_projects_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspaces_pull_request_id_pull_requests_id_fk": {
+ "name": "workspaces_pull_request_id_pull_requests_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "pull_requests",
+ "columnsFrom": [
+ "pull_request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json
index 055ebf3d6ac..252c0e1e1cb 100644
--- a/packages/host-service/drizzle/meta/_journal.json
+++ b/packages/host-service/drizzle/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1777572778580,
"tag": "0004_mean_blacklash",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "6",
+ "when": 1779922528385,
+ "tag": "0005_host_settings_and_project_overrides",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts
index ed12e47044e..fcbbb6cda82 100644
--- a/packages/host-service/src/db/schema.ts
+++ b/packages/host-service/src/db/schema.ts
@@ -1,3 +1,4 @@
+import type { BranchPrefixMode } from "@superset/shared/workspace-launch";
import {
index,
integer,
@@ -39,6 +40,11 @@ export const projects = sqliteTable(
repoName: text("repo_name"),
repoUrl: text("repo_url"),
remoteName: text("remote_name"),
+ worktreeBaseDir: text("worktree_base_dir"),
+ // Per-project branch-prefix override. A null `branchPrefixMode` means
+ // "fall back to the host-wide default" in `host_settings`.
+ branchPrefixMode: text("branch_prefix_mode").$type(),
+ branchPrefixCustom: text("branch_prefix_custom"),
createdAt: integer("created_at")
.notNull()
.$defaultFn(() => Date.now()),
@@ -46,6 +52,19 @@ export const projects = sqliteTable(
(table) => [index("projects_repo_path_idx").on(table.repoPath)],
);
+/**
+ * Single-row host-wide settings (always `id = 1`). The host-service has no
+ * generic settings store yet; this row holds host-wide knobs (worktree base
+ * dir, branch-prefix default) that projects fall back to when they have no
+ * override of their own.
+ */
+export const hostSettings = sqliteTable("host_settings", {
+ id: integer().primaryKey().default(1),
+ worktreeBaseDir: text("worktree_base_dir"),
+ branchPrefixMode: text("branch_prefix_mode").$type(),
+ branchPrefixCustom: text("branch_prefix_custom"),
+});
+
export const pullRequests = sqliteTable(
"pull_requests",
{
diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts
index 1a41d1b3547..668e75e32cd 100644
--- a/packages/host-service/src/trpc/router/project/project.ts
+++ b/packages/host-service/src/trpc/router/project/project.ts
@@ -3,12 +3,14 @@ import {
type ParsedGitHubRemote,
parseGitHubRemote,
} from "@superset/shared/github-remote";
+import { BRANCH_PREFIX_MODES } from "@superset/shared/workspace-launch";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { projects, workspaces } from "../../../db/schema";
import { createUserSimpleGit } from "../../../runtime/git/simple-git";
import { protectedProcedure, router } from "../../index";
+import { normalizeWorktreeBaseDir } from "../workspace-creation/shared/worktree-paths";
import {
createFromClone,
createFromEmpty,
@@ -34,6 +36,7 @@ export const projectRouter = router({
repoOwner: projects.repoOwner,
repoName: projects.repoName,
repoUrl: projects.repoUrl,
+ worktreeBaseDir: projects.worktreeBaseDir,
})
.from(projects)
.all();
@@ -50,6 +53,9 @@ export const projectRouter = router({
repoOwner: projects.repoOwner,
repoName: projects.repoName,
repoUrl: projects.repoUrl,
+ worktreeBaseDir: projects.worktreeBaseDir,
+ branchPrefixMode: projects.branchPrefixMode,
+ branchPrefixCustom: projects.branchPrefixCustom,
})
.from(projects)
.where(eq(projects.id, input.projectId))
@@ -57,6 +63,68 @@ export const projectRouter = router({
);
}),
+ setWorktreeBaseDir: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ path: z.string().nullable(),
+ }),
+ )
+ .mutation(({ ctx, input }) => {
+ const worktreeBaseDir = normalizeWorktreeBaseDir(input.path);
+ ctx.db
+ .update(projects)
+ .set({ worktreeBaseDir })
+ .where(eq(projects.id, input.projectId))
+ .run();
+
+ const project = ctx.db.query.projects
+ .findFirst({ where: eq(projects.id, input.projectId) })
+ .sync();
+ if (!project) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Project is not set up on this host",
+ });
+ }
+
+ return {
+ id: project.id,
+ worktreeBaseDir: project.worktreeBaseDir ?? null,
+ };
+ }),
+
+ /**
+ * Set this project's branch-prefix override. A `null` mode clears the
+ * override so the project falls back to the host-wide default.
+ */
+ setBranchPrefix: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string().uuid(),
+ mode: z.enum(BRANCH_PREFIX_MODES).nullable(),
+ customPrefix: z.string().nullable().optional(),
+ }),
+ )
+ .mutation(({ ctx, input }) => {
+ const updated = ctx.db
+ .update(projects)
+ .set({
+ branchPrefixMode: input.mode,
+ branchPrefixCustom: input.customPrefix ?? null,
+ })
+ .where(eq(projects.id, input.projectId))
+ .returning({ id: projects.id })
+ .get();
+ if (!updated) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Project not set up locally: ${input.projectId}`,
+ });
+ }
+ return { success: true as const };
+ }),
+
findBackfillConflict: protectedProcedure
.input(
z.object({
diff --git a/packages/host-service/src/trpc/router/settings/branch-prefix.ts b/packages/host-service/src/trpc/router/settings/branch-prefix.ts
new file mode 100644
index 00000000000..1c9fb49d106
--- /dev/null
+++ b/packages/host-service/src/trpc/router/settings/branch-prefix.ts
@@ -0,0 +1,59 @@
+import {
+ BRANCH_PREFIX_MODES,
+ type BranchPrefixMode,
+} from "@superset/shared/workspace-launch";
+import { z } from "zod";
+import { hostSettings } from "../../../db/schema";
+import { createUserSimpleGit } from "../../../runtime/git/simple-git";
+import { protectedProcedure, router } from "../../index";
+import { resolveGitInfo } from "../workspace-creation/utils/branch-prefix";
+
+/**
+ * Host-wide branch-prefix default. Projects without their own override fall
+ * back to this. Stored in the single-row `host_settings` table (`id = 1`).
+ */
+export const branchPrefixRouter = router({
+ /** The host-wide default. `none` when never configured. */
+ get: protectedProcedure.query(({ ctx }) => {
+ const row = ctx.db.select().from(hostSettings).get();
+ return {
+ mode: (row?.branchPrefixMode ?? "none") satisfies BranchPrefixMode,
+ customPrefix: row?.branchPrefixCustom ?? null,
+ };
+ }),
+
+ /** Set the host-wide default, upserting the single settings row. */
+ set: protectedProcedure
+ .input(
+ z.object({
+ mode: z.enum(BRANCH_PREFIX_MODES),
+ customPrefix: z.string().nullable().optional(),
+ }),
+ )
+ .mutation(({ ctx, input }) => {
+ ctx.db
+ .insert(hostSettings)
+ .values({
+ id: 1,
+ branchPrefixMode: input.mode,
+ branchPrefixCustom: input.customPrefix ?? null,
+ })
+ .onConflictDoUpdate({
+ target: hostSettings.id,
+ set: {
+ branchPrefixMode: input.mode,
+ branchPrefixCustom: input.customPrefix ?? null,
+ },
+ })
+ .run();
+ return { success: true as const };
+ }),
+
+ /**
+ * Git identity for the settings preview — lets the UI show what the
+ * `author`/`github` modes would actually resolve to.
+ */
+ gitInfo: protectedProcedure.query(({ ctx }) => {
+ return resolveGitInfo(createUserSimpleGit(), ctx.execGh);
+ }),
+});
diff --git a/packages/host-service/src/trpc/router/settings/index.ts b/packages/host-service/src/trpc/router/settings/index.ts
index 18b91e73211..cd2e7378e95 100644
--- a/packages/host-service/src/trpc/router/settings/index.ts
+++ b/packages/host-service/src/trpc/router/settings/index.ts
@@ -1,8 +1,13 @@
import { router } from "../../index";
import { agentConfigsRouter } from "./agent-configs";
+import { branchPrefixRouter } from "./branch-prefix";
+import { worktreeLocationRouter } from "./worktree-location";
export const settingsRouter = router({
agentConfigs: agentConfigsRouter,
+ branchPrefix: branchPrefixRouter,
+ worktreeLocation: worktreeLocationRouter,
});
export type { HostAgentConfig } from "./agent-configs";
+export type { HostWorktreeLocationSettings } from "./worktree-location";
diff --git a/packages/host-service/src/trpc/router/settings/worktree-location.ts b/packages/host-service/src/trpc/router/settings/worktree-location.ts
new file mode 100644
index 00000000000..9fe31568579
--- /dev/null
+++ b/packages/host-service/src/trpc/router/settings/worktree-location.ts
@@ -0,0 +1,75 @@
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+import { hostSettings } from "../../../db/schema";
+import type { HostServiceContext } from "../../../types";
+import { protectedProcedure, router } from "../../index";
+import {
+ defaultWorktreesRoot,
+ normalizeWorktreeBaseDir,
+} from "../workspace-creation/shared/worktree-paths";
+
+const HOST_SETTINGS_ID = 1;
+// Set by the desktop coordinator from the v1 user setting so a first-run
+// host-service inherits the previous worktree location instead of silently
+// falling back to the default.
+const LEGACY_WORKTREE_BASE_DIR_ENV = "SUPERSET_LEGACY_WORKTREE_BASE_DIR";
+
+export interface HostWorktreeLocationSettings {
+ worktreeBaseDir: string | null;
+ defaultWorktreeBaseDir: string;
+}
+
+function toOutput(
+ worktreeBaseDir: string | null,
+): HostWorktreeLocationSettings {
+ return {
+ worktreeBaseDir,
+ defaultWorktreeBaseDir: defaultWorktreesRoot(),
+ };
+}
+
+export function getHostWorktreeBaseDir(
+ ctx: Pick,
+): string | null {
+ const existing = ctx.db
+ .select({ worktreeBaseDir: hostSettings.worktreeBaseDir })
+ .from(hostSettings)
+ .where(eq(hostSettings.id, HOST_SETTINGS_ID))
+ .get();
+ if (existing) return existing.worktreeBaseDir ?? null;
+
+ // v1 didn't validate paths, so a malformed legacy value shouldn't brick
+ // the first .get() — treat anything that won't normalize as "no legacy".
+ let legacy: string | null = null;
+ try {
+ legacy = normalizeWorktreeBaseDir(
+ process.env[LEGACY_WORKTREE_BASE_DIR_ENV],
+ );
+ } catch {}
+ ctx.db
+ .insert(hostSettings)
+ .values({ id: HOST_SETTINGS_ID, worktreeBaseDir: legacy })
+ .run();
+ return legacy;
+}
+
+export const worktreeLocationRouter = router({
+ get: protectedProcedure.query(({ ctx }) =>
+ toOutput(getHostWorktreeBaseDir(ctx)),
+ ),
+
+ set: protectedProcedure
+ .input(z.object({ path: z.string().nullable() }))
+ .mutation(({ ctx, input }) => {
+ const worktreeBaseDir = normalizeWorktreeBaseDir(input.path);
+ ctx.db
+ .insert(hostSettings)
+ .values({ id: HOST_SETTINGS_ID, worktreeBaseDir })
+ .onConflictDoUpdate({
+ target: hostSettings.id,
+ set: { worktreeBaseDir },
+ })
+ .run();
+ return toOutput(worktreeBaseDir);
+ }),
+});
diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts
index 8eda98c8a5a..0661d247bd2 100644
--- a/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts
+++ b/packages/host-service/src/trpc/router/workspace-creation/shared/worktree-paths.ts
@@ -1,22 +1,49 @@
import { homedir } from "node:os";
-import { join, resolve, sep } from "node:path";
+import { isAbsolute, join, normalize, resolve, sep } from "node:path";
import { TRPCError } from "@trpc/server";
// Kept outside the primary checkout so editors, file watchers, and
// ignore rules treat worktrees as separate trees, not nested ones.
-function supersetWorktreesRoot(): string {
+export function defaultWorktreesRoot(): string {
return join(homedir(), ".superset", "worktrees");
}
-export function projectWorktreesRoot(projectId: string): string {
- return resolve(supersetWorktreesRoot(), projectId);
+export function normalizeWorktreeBaseDir(
+ input: string | null | undefined,
+): string | null {
+ const trimmed = input?.trim();
+ if (!trimmed) return null;
+
+ if (trimmed.startsWith("~")) {
+ const rest = trimmed.slice(1);
+ if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) {
+ return normalize(join(homedir(), rest));
+ }
+ }
+
+ if (!isAbsolute(trimmed)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Worktree location must be an absolute path or start with ~",
+ });
+ }
+
+ return resolve(trimmed);
+}
+
+export function projectWorktreesRoot(
+ projectId: string,
+ worktreeBaseDir?: string | null,
+): string {
+ return resolve(worktreeBaseDir ?? defaultWorktreesRoot(), projectId);
}
export function safeResolveWorktreePath(
projectId: string,
branchName: string,
+ worktreeBaseDir?: string | null,
): string {
- const projectRoot = projectWorktreesRoot(projectId);
+ const projectRoot = projectWorktreesRoot(projectId, worktreeBaseDir);
const worktreePath = resolve(projectRoot, branchName);
if (
worktreePath !== projectRoot &&
diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts
new file mode 100644
index 00000000000..1940a66f172
--- /dev/null
+++ b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.test.ts
@@ -0,0 +1,140 @@
+import { Database } from "bun:sqlite";
+import { describe, expect, it } from "bun:test";
+import { resolve } from "node:path";
+import type { BranchPrefixMode } from "@superset/shared/workspace-launch";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
+import type { SimpleGit } from "simple-git";
+import * as schema from "../../../../db/schema";
+import { hostSettings } from "../../../../db/schema";
+import type { HostServiceContext } from "../../../../types";
+import type { LocalProject } from "../shared/local-project";
+import { resolveProjectBranchPrefix } from "./branch-prefix";
+
+const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../../drizzle");
+
+function createTestDb() {
+ const sqlite = new Database(":memory:");
+ const db = drizzle(sqlite, { schema });
+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
+ return db;
+}
+
+type TestDb = ReturnType;
+
+/** Git stub whose `user.name` is fixed — only the `author` mode reads it. */
+function gitWithAuthor(authorName: string | null): SimpleGit {
+ return {
+ getConfig: async () => ({ value: authorName }),
+ } as unknown as SimpleGit;
+}
+
+function makeProject(overrides: Partial): LocalProject {
+ return {
+ id: "00000000-0000-0000-0000-000000000000",
+ repoPath: "/tmp/repo",
+ branchPrefixMode: null,
+ branchPrefixCustom: null,
+ ...overrides,
+ } as LocalProject;
+}
+
+function makeCtx(db: TestDb): HostServiceContext {
+ return { db } as unknown as HostServiceContext;
+}
+
+function setGlobal(
+ db: TestDb,
+ mode: BranchPrefixMode | null,
+ customPrefix: string | null,
+) {
+ db.insert(hostSettings)
+ .values({ id: 1, branchPrefixMode: mode, branchPrefixCustom: customPrefix })
+ .run();
+}
+
+describe("resolveProjectBranchPrefix", () => {
+ it("returns undefined when nothing is configured", async () => {
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(createTestDb()),
+ project: makeProject({}),
+ git: gitWithAuthor(null),
+ existingBranches: [],
+ });
+ expect(result).toBeUndefined();
+ });
+
+ it("falls back to the host-wide default", async () => {
+ const db = createTestDb();
+ setGlobal(db, "custom", "team");
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(db),
+ project: makeProject({}),
+ git: gitWithAuthor(null),
+ existingBranches: [],
+ });
+ expect(result).toBe("team");
+ });
+
+ it("project override wins over the host-wide default", async () => {
+ const db = createTestDb();
+ setGlobal(db, "custom", "team");
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(db),
+ project: makeProject({
+ branchPrefixMode: "custom",
+ branchPrefixCustom: "proj",
+ }),
+ git: gitWithAuthor(null),
+ existingBranches: [],
+ });
+ expect(result).toBe("proj");
+ });
+
+ it("a null project mode inherits the host-wide default", async () => {
+ const db = createTestDb();
+ setGlobal(db, "custom", "team");
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(db),
+ // branchPrefixCustom set but mode null — must NOT count as an override.
+ project: makeProject({ branchPrefixCustom: "stale" }),
+ git: gitWithAuthor(null),
+ existingBranches: [],
+ });
+ expect(result).toBe("team");
+ });
+
+ it("a project `none` override suppresses the host-wide default", async () => {
+ const db = createTestDb();
+ setGlobal(db, "custom", "team");
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(db),
+ project: makeProject({ branchPrefixMode: "none" }),
+ git: gitWithAuthor(null),
+ existingBranches: [],
+ });
+ expect(result).toBeUndefined();
+ });
+
+ it("drops the prefix when it collides with an existing branch", async () => {
+ const db = createTestDb();
+ setGlobal(db, "custom", "team");
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(db),
+ project: makeProject({}),
+ git: gitWithAuthor(null),
+ existingBranches: ["main", "Team"],
+ });
+ expect(result).toBeUndefined();
+ });
+
+ it("resolves the `author` mode from git user.name", async () => {
+ const result = await resolveProjectBranchPrefix({
+ ctx: makeCtx(createTestDb()),
+ project: makeProject({ branchPrefixMode: "author" }),
+ git: gitWithAuthor("Jane Doe"),
+ existingBranches: [],
+ });
+ expect(result).toBe("Jane-Doe");
+ });
+});
diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts
new file mode 100644
index 00000000000..0b564378c97
--- /dev/null
+++ b/packages/host-service/src/trpc/router/workspace-creation/utils/branch-prefix.ts
@@ -0,0 +1,102 @@
+import {
+ type BranchPrefixMode,
+ resolveBranchPrefix,
+} from "@superset/shared/workspace-launch";
+import type { SimpleGit } from "simple-git";
+import { hostSettings } from "../../../../db/schema";
+import type { HostServiceContext } from "../../../../types";
+import type { LocalProject } from "../shared/local-project";
+import type { ExecGh } from "./exec-gh";
+
+/** Reads `user.name` from git config. Returns null when unset or unreadable. */
+export async function getGitAuthorName(git: SimpleGit): Promise {
+ try {
+ const name = await git.getConfig("user.name");
+ return name.value?.trim() || null;
+ } catch (error) {
+ console.warn("[branch-prefix] failed to read git user.name:", error);
+ return null;
+ }
+}
+
+/** Resolves the authenticated GitHub username via `gh api user`. */
+export async function getGitHubUsername(
+ execGh: ExecGh,
+): Promise {
+ try {
+ const result = await execGh(["api", "user", "--jq", ".login"]);
+ return typeof result === "string" && result.trim() ? result.trim() : null;
+ } catch (error) {
+ console.warn("[branch-prefix] failed to read GitHub username:", error);
+ return null;
+ }
+}
+
+export interface ResolvedGitInfo {
+ githubUsername: string | null;
+ authorName: string | null;
+}
+
+/** Git identity used to preview `author`/`github` prefixes in settings. */
+export async function resolveGitInfo(
+ git: SimpleGit,
+ execGh: ExecGh,
+): Promise {
+ const [githubUsername, authorName] = await Promise.all([
+ getGitHubUsername(execGh),
+ getGitAuthorName(git),
+ ]);
+ return { githubUsername, authorName };
+}
+
+/**
+ * Resolves the branch prefix to apply to a *new* branch in this project.
+ *
+ * A project-level override (any non-null `branchPrefixMode`) wins over the
+ * host-wide default in `host_settings`; absent both, no prefix is applied.
+ * The resolved prefix is dropped when it would collide with an existing
+ * branch name — git can't hold both `censys` and `censys/foo`.
+ *
+ * Returns the prefix segment (e.g. `censys`) or `undefined` for no prefix.
+ */
+export async function resolveProjectBranchPrefix({
+ ctx,
+ project,
+ git,
+ existingBranches,
+}: {
+ ctx: HostServiceContext;
+ project: LocalProject;
+ git: SimpleGit;
+ existingBranches: string[];
+}): Promise {
+ const global = ctx.db.select().from(hostSettings).get();
+ // Project override wins; otherwise fall back to the host-wide default.
+ const source = project.branchPrefixMode != null ? project : global;
+ const mode: BranchPrefixMode = source?.branchPrefixMode ?? "none";
+ const customPrefix = source?.branchPrefixCustom ?? null;
+
+ if (mode === "none") return undefined;
+
+ let authorName: string | null = null;
+ let githubUsername: string | null = null;
+ if (mode === "author") {
+ authorName = await getGitAuthorName(git);
+ } else if (mode === "github") {
+ [githubUsername, authorName] = await Promise.all([
+ getGitHubUsername(ctx.execGh),
+ getGitAuthorName(git),
+ ]);
+ }
+
+ const prefix = resolveBranchPrefix({
+ mode,
+ customPrefix,
+ authorPrefix: authorName,
+ githubUsername,
+ });
+ if (!prefix) return undefined;
+
+ const existingSet = new Set(existingBranches.map((b) => b.toLowerCase()));
+ return existingSet.has(prefix.toLowerCase()) ? undefined : prefix;
+}
diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
index fdd55e983ca..aeee21af4e6 100644
--- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts
+++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
@@ -16,6 +16,7 @@ import type { HostServiceContext } from "../../../types";
import { protectedProcedure, router } from "../../index";
import { type AgentRunResult, runAgentInWorkspace } from "../agents";
import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace";
+import { getHostWorktreeBaseDir } from "../settings/worktree-location";
import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree";
import {
getWorktreeBranchAtPath,
@@ -32,6 +33,7 @@ import {
type GeneratedWorkspaceNames,
generateWorkspaceNamesFromPrompt,
} from "../workspace-creation/utils/ai-workspace-names";
+import { resolveProjectBranchPrefix } from "../workspace-creation/utils/branch-prefix";
import type { ExecGh } from "../workspace-creation/utils/exec-gh";
import { listBranchNames } from "../workspace-creation/utils/list-branch-names";
import {
@@ -560,6 +562,8 @@ export const workspacesRouter = router({
await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath);
const git = await ctx.git(localProject.repoPath);
+ const worktreeBaseDir =
+ localProject.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx);
// Free branches still claimed by registrations whose dirs are
// gone — without this, `git worktree add` later fails with
@@ -670,6 +674,7 @@ export const workspacesRouter = router({
worktreePath = safeResolveWorktreePath(
localProject.id,
resolvedBranch,
+ worktreeBaseDir,
);
mkdirSync(dirname(worktreePath), { recursive: true });
@@ -828,12 +833,31 @@ export const workspacesRouter = router({
// Typed branch: resolve start point via the existing-branch-
// aware planner. Title-rename can race with that lookup.
resolvedBranch = typedBranch;
- const [planResult, aiNames] = await Promise.all([
+ const [planResult, aiNames, existing] = await Promise.all([
planBranchSource(git, resolvedBranch, input.baseBranch),
aiNamesPromise ?? Promise.resolve(null),
+ listBranchNames(ctx, localProject.repoPath),
]);
plan = planResult;
aiTitle = aiNames?.title ?? null;
+ // Namespace newly-created branches under the configured
+ // prefix. A typed branch that resolves to an existing ref is
+ // checked out as-is and never re-prefixed.
+ if (!plan.usedExistingBranch) {
+ const prefix = await resolveProjectBranchPrefix({
+ ctx,
+ project: localProject,
+ git,
+ existingBranches: existing,
+ });
+ if (prefix) {
+ resolvedBranch = deduplicateBranchName(
+ `${prefix}/${resolvedBranch}`,
+ existing,
+ );
+ plan = { ...plan, branch: resolvedBranch };
+ }
+ }
} else {
// Auto-gen branch: kick the LLM, the start-point resolve,
// and the dedupe list off in parallel — none of them depend
@@ -846,8 +870,15 @@ export const workspacesRouter = router({
listBranchNames(ctx, localProject.repoPath),
]);
aiTitle = aiNames?.title ?? null;
+ const prefix = await resolveProjectBranchPrefix({
+ ctx,
+ project: localProject,
+ git,
+ existingBranches: existing,
+ });
const candidate = aiNames?.branchName || generateFriendlyBranchName();
- resolvedBranch = deduplicateBranchName(candidate, existing);
+ const prefixed = prefix ? `${prefix}/${candidate}` : candidate;
+ resolvedBranch = deduplicateBranchName(prefixed, existing);
plan = {
branch: resolvedBranch,
startPoint,
@@ -895,6 +926,7 @@ export const workspacesRouter = router({
worktreePath = safeResolveWorktreePath(
localProject.id,
resolvedBranch,
+ worktreeBaseDir,
);
mkdirSync(dirname(worktreePath), { recursive: true });
diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts
index a1cc4f9cd77..ed75e43106f 100644
--- a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts
+++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
-import { existsSync, rmSync } from "node:fs";
+import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
import { join } from "node:path";
import { TRPCClientError } from "@trpc/client";
import { eq } from "drizzle-orm";
@@ -50,6 +51,128 @@ describe("workspace.create + workspace.delete integration", () => {
expect(existsSync(persisted?.worktreePath ?? "")).toBe(true);
});
+ test("create() uses the configured host worktree location", async () => {
+ const customRoot = realpathSync(
+ mkdtempSync(join(tmpdir(), "host-service-worktrees-")),
+ );
+
+ try {
+ const scenario = await createProjectScenario({
+ hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() },
+ });
+ dispose = scenario.dispose;
+
+ await scenario.host.trpc.settings.worktreeLocation.set.mutate({
+ path: customRoot,
+ });
+
+ const result = await scenario.host.trpc.workspaces.create.mutate({
+ projectId: scenario.projectId,
+ name: "custom root",
+ branch: "feature/custom-root",
+ });
+
+ const persisted = scenario.host.db
+ .select()
+ .from(workspaces)
+ .where(eq(workspaces.id, result?.workspace?.id ?? ""))
+ .get();
+
+ expect(persisted?.worktreePath).toBe(
+ join(customRoot, scenario.projectId, "feature", "custom-root"),
+ );
+ expect(existsSync(persisted?.worktreePath ?? "")).toBe(true);
+ } finally {
+ rmSync(customRoot, { recursive: true, force: true });
+ }
+ });
+
+ test("create() seeds the host location from the legacy desktop setting", async () => {
+ const previousLegacyValue = process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR;
+ const legacyRoot = realpathSync(
+ mkdtempSync(join(tmpdir(), "host-service-worktrees-legacy-")),
+ );
+ process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR = legacyRoot;
+
+ try {
+ const scenario = await createProjectScenario({
+ hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() },
+ });
+ dispose = scenario.dispose;
+
+ const result = await scenario.host.trpc.workspaces.create.mutate({
+ projectId: scenario.projectId,
+ name: "legacy root",
+ branch: "feature/legacy-root",
+ });
+
+ const persisted = scenario.host.db
+ .select()
+ .from(workspaces)
+ .where(eq(workspaces.id, result?.workspace?.id ?? ""))
+ .get();
+ const settings =
+ await scenario.host.trpc.settings.worktreeLocation.get.query();
+
+ expect(settings.worktreeBaseDir).toBe(legacyRoot);
+ expect(persisted?.worktreePath).toBe(
+ join(legacyRoot, scenario.projectId, "feature", "legacy-root"),
+ );
+ } finally {
+ if (previousLegacyValue === undefined) {
+ delete process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR;
+ } else {
+ process.env.SUPERSET_LEGACY_WORKTREE_BASE_DIR = previousLegacyValue;
+ }
+ rmSync(legacyRoot, { recursive: true, force: true });
+ }
+ });
+
+ test("create() lets a project override the host worktree location", async () => {
+ const hostRoot = realpathSync(
+ mkdtempSync(join(tmpdir(), "host-service-worktrees-host-")),
+ );
+ const projectRoot = realpathSync(
+ mkdtempSync(join(tmpdir(), "host-service-worktrees-project-")),
+ );
+
+ try {
+ const scenario = await createProjectScenario({
+ hostOptions: { apiOverrides: cloudFlows.workspaceCreateOk() },
+ });
+ dispose = scenario.dispose;
+
+ await scenario.host.trpc.settings.worktreeLocation.set.mutate({
+ path: hostRoot,
+ });
+ await scenario.host.trpc.project.setWorktreeBaseDir.mutate({
+ projectId: scenario.projectId,
+ path: projectRoot,
+ });
+
+ const result = await scenario.host.trpc.workspaces.create.mutate({
+ projectId: scenario.projectId,
+ name: "project root",
+ branch: "feature/project-root",
+ });
+
+ const persisted = scenario.host.db
+ .select()
+ .from(workspaces)
+ .where(eq(workspaces.id, result?.workspace?.id ?? ""))
+ .get();
+
+ expect(persisted?.worktreePath).toBe(
+ join(projectRoot, scenario.projectId, "feature", "project-root"),
+ );
+ expect(persisted?.worktreePath.startsWith(hostRoot)).toBe(false);
+ expect(existsSync(persisted?.worktreePath ?? "")).toBe(true);
+ } finally {
+ rmSync(hostRoot, { recursive: true, force: true });
+ rmSync(projectRoot, { recursive: true, force: true });
+ }
+ });
+
test("create() adopts an existing worktree at a non-canonical path instead of failing on `git worktree add`", async () => {
// Regress: when the user typed a branch that already has a worktree
// somewhere outside `~/.superset/worktrees//`,
diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts
index 0a2b200fd6b..c2060863e3f 100644
--- a/packages/local-db/src/schema/zod.ts
+++ b/packages/local-db/src/schema/zod.ts
@@ -194,17 +194,10 @@ export const TERMINAL_LINK_BEHAVIORS = [
export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number];
-/**
- * Branch prefix modes for workspace branch naming
- */
-export const BRANCH_PREFIX_MODES = [
- "none",
- "github",
- "author",
- "custom",
-] as const;
-
-export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number];
+export {
+ BRANCH_PREFIX_MODES,
+ type BranchPrefixMode,
+} from "@superset/shared/workspace-launch";
export const FILE_OPEN_MODES = ["split-pane", "new-tab"] as const;
diff --git a/packages/shared/src/workspace-launch/branch.ts b/packages/shared/src/workspace-launch/branch.ts
index cc993440eae..cd6d4475366 100644
--- a/packages/shared/src/workspace-launch/branch.ts
+++ b/packages/shared/src/workspace-launch/branch.ts
@@ -1,6 +1,25 @@
export const DEFAULT_BRANCH_SEGMENT_MAX_LENGTH = 50;
export const DEFAULT_BRANCH_NAME_MAX_LENGTH = 100;
+/**
+ * Branch prefix modes for workspace branch naming. Single source of truth;
+ * `@superset/local-db` re-exports these so callers that can't depend on
+ * local-db (host-service) share the same definition.
+ *
+ * - `none`: no prefix
+ * - `github`: the user's GitHub username
+ * - `author`: the git `user.name` author name
+ * - `custom`: a user-defined string
+ */
+export const BRANCH_PREFIX_MODES = [
+ "none",
+ "github",
+ "author",
+ "custom",
+] as const;
+
+export type BranchPrefixMode = (typeof BRANCH_PREFIX_MODES)[number];
+
interface SanitizeSegmentOptions {
preserveCase?: boolean;
}
@@ -170,7 +189,7 @@ export function resolveBranchPrefix({
authorPrefix,
githubUsername,
}: {
- mode: "github" | "author" | "custom" | "none" | null | undefined;
+ mode: BranchPrefixMode | null | undefined;
customPrefix?: string | null;
authorPrefix?: string | null;
githubUsername?: string | null;
diff --git a/plans/v2-branch-prefixes-design.md b/plans/v2-branch-prefixes-design.md
new file mode 100644
index 00000000000..14867c80cab
--- /dev/null
+++ b/plans/v2-branch-prefixes-design.md
@@ -0,0 +1,202 @@
+# V2 configurable branch prefixes — retrospective design doc
+
+**Ticket:** SUPER-835 · **Branch:** `configurable-branch-prefi` ·
+**Footprint:** +1412 / −15 across 22 files
+
+## Why the diff stat looked huge
+
+`git diff main..HEAD --stat` reports ~2200 added / ~1979 deleted across 79
+files, but that is comparing two diverged tips. Against the merge-base
+(`59a2a341a`), the actual branch footprint is **+1412 / −15 across 22
+files**. The deletions in the misleading stat (`full-disk-access.test.ts`,
+`argv.test.ts`, `focusTerminalPane/`, `setup-script-prompt.md`, etc.) are
+work landed on `main` *after* this branch was cut and are not part of this
+change. Of the real 1412 added lines, ~640 are the auto-generated drizzle
+snapshot (`0005_snapshot.json`) and ~140 are tests — the hand-written
+feature surface is ~600 lines.
+
+## Goal
+
+Bring v1's "branch prefix" feature to the v2 (host-service) workspace flow.
+Requested by Censys for an org-wide v2 trial: new workspace branches should
+be namespaced under a configurable segment (e.g. `censys/my-feature`,
+`kho/my-feature`) so multi-user remotes stay tidy.
+
+## Behaviour (v1 parity)
+
+| Mode | Resolves to |
+|----------|-----------------------------------|
+| `none` | no prefix |
+| `github` | the authed `gh` user's login |
+| `author` | git `user.name` |
+| `custom` | a user-typed string (sanitized) |
+
+- **Two levels of configuration**: a host-wide global default plus an
+ optional per-project override. Project override wins when its mode is set;
+ otherwise the global default applies; otherwise `none`.
+- **Applied to new branches only.** Auto-generated names (AI / friendly
+ random) and typed branch names that don't already exist get prefixed.
+ Existing branches and PR checkouts are never re-prefixed.
+- **Collision guard.** If the resolved prefix equals an existing branch
+ name, it's dropped — git cannot hold both `censys` and `censys/foo`.
+- **Dedupe survives prefixing.** `deduplicateBranchName` runs *after* the
+ prefix is applied, so `censys/login` colliding with an existing branch
+ becomes `censys/login-2` rather than `censys/login` being abandoned.
+
+## Storage
+
+v1 stored both settings in the desktop's local SQLite (per-user, per
+machine). v2 runs workspace creation inside `packages/host-service`, which
+has its own SQLite DB — so v2 storage is **host-local**, mirroring the v1
+locality decision but on a different physical store.
+
+Two new columns and one new table (`drizzle/0005_branch_prefix_settings.sql`):
+
+```sql
+CREATE TABLE host_settings (
+ id integer PRIMARY KEY DEFAULT 1 NOT NULL,
+ branch_prefix_mode text,
+ branch_prefix_custom text
+);
+ALTER TABLE projects ADD branch_prefix_mode text;
+ALTER TABLE projects ADD branch_prefix_custom text;
+```
+
+- `host_settings` is a single-row table (`id = 1`, upserted on
+ `onConflictDoUpdate`). The host-service has no generic settings store
+ today; this is the minimum-viable shape for the one global setting v2
+ needs. If a second host-level setting appears, this generalizes to a
+ proper key/value or wider columns then.
+- `projects.branch_prefix_mode = NULL` means "inherit the host default."
+ Any non-null value (including `'none'`) is an explicit override.
+
+## Module map
+
+```
+packages/shared/src/workspace-launch/branch.ts
+ • BRANCH_PREFIX_MODES (single source of truth) + BranchPrefixMode
+ • resolveBranchPrefix({ mode, customPrefix, authorPrefix, githubUsername })
+ — already existed; now consumes the shared type
+
+packages/local-db/src/schema/zod.ts
+ • Re-exports BRANCH_PREFIX_MODES / BranchPrefixMode from shared
+ (host-service cannot depend on local-db)
+
+packages/host-service/src/db/schema.ts
+ • projects.branchPrefixMode / branchPrefixCustom
+ • hostSettings table
+
+packages/host-service/src/trpc/router/
+ ├ settings/branch-prefix.ts — { get, set, gitInfo } host-wide
+ ├ project/project.ts → setBranchPrefix — per-project override
+ └ workspaces/workspaces.ts — invokes prefix during create
+ └ workspace-creation/utils/branch-prefix.ts
+ • getGitAuthorName (git user.name, null on failure)
+ • getGitHubUsername (gh api user --jq .login, null on failure)
+ • resolveGitInfo (parallel, for the settings preview)
+ • resolveProjectBranchPrefix (cascade + collision guard)
+
+apps/desktop/src/renderer/routes/_authenticated/settings/
+ ├ components/BranchPrefixControl/ — shared select+input
+ ├ git/components/V2GitSettings/ — host-wide default UI
+ └ v2-project/$projectId/.../BranchPrefixSection/ — per-project override UI
+```
+
+## tRPC surface
+
+| Procedure | Shape |
+|----------------------------------------|------------------------------------------------------------------------------------------------|
+| `settings.branchPrefix.get` | `→ { mode: BranchPrefixMode, customPrefix: string \| null }` |
+| `settings.branchPrefix.set` | `{ mode, customPrefix? } →` |
+| `settings.branchPrefix.gitInfo` | `→ { githubUsername, authorName }` — drives the preview chip |
+| `project.get` | now also returns `branchPrefixMode`, `branchPrefixCustom` |
+| `project.setBranchPrefix` | `{ projectId, mode: BranchPrefixMode \| null, customPrefix? } →` — `null` mode clears override |
+
+## Resolution at workspace-create time
+
+`workspaces.create` calls `resolveProjectBranchPrefix` along both branches
+of the existing typed-vs-auto-generated split:
+
+```
+typed branch path:
+ plan = planBranchSource(...)
+ if (!plan.usedExistingBranch):
+ prefix = resolveProjectBranchPrefix(...)
+ if (prefix):
+ resolvedBranch = deduplicateBranchName(`${prefix}/${typed}`, existing)
+ plan.branch = resolvedBranch
+
+auto-generated path:
+ prefix = resolveProjectBranchPrefix(...)
+ candidate = aiNames?.branchName || generateFriendlyBranchName()
+ prefixed = prefix ? `${prefix}/${candidate}` : candidate
+ resolvedBranch = deduplicateBranchName(prefixed, existing)
+```
+
+Three properties this gets right that are easy to get wrong:
+1. **Existing-branch checkouts skip prefixing** (`plan.usedExistingBranch`
+ gate). PR checkouts and `git checkout existing-name` workflows are
+ untouched.
+2. **`listBranchNames` is fetched in the same `Promise.all`** that already
+ loads the AI title — no extra serial git call.
+3. **Dedupe runs after prefixing**, so the collision guard handles "prefix
+ equals an existing branch name," and `deduplicateBranchName` handles
+ "full prefixed name collides."
+
+## Renderer
+
+`BranchPrefixControl` is the shared select+(conditional custom input) used
+by both surfaces. The two consumers differ only in whether the dropdown
+includes a `"Use global default"` entry (`showDefault` prop) — the
+host-wide setting cannot itself defer to a default, so it omits it.
+
+Custom-prefix behavior worth calling out:
+- The text input is locally controlled and syncs from props via `useEffect`,
+ so optimistic edits don't fight the query.
+- `onBlur` sanitizes with `sanitizeSegment` from `@superset/shared`.
+- An empty sanitized prefix on blur is treated as "still typing": the input
+ clears but no mutation fires. This avoids persisting
+ `{ mode: 'custom', customPrefix: null }`, which would lie about user
+ intent — the only way to leave `custom` mode is via the dropdown.
+
+## Decisions / tradeoffs
+
+- **Why a `host_settings` table and not a key/value store?** YAGNI. One
+ global setting today; a real settings store can be introduced when the
+ second one appears. The `id = 1` upsert keeps the call site simple.
+- **Why not put `BRANCH_PREFIX_MODES` in `@superset/local-db`?**
+ `host-service` can't depend on `local-db`. The constants moved to
+ `@superset/shared/workspace-launch` (next to `resolveBranchPrefix`); the
+ old `local-db` export is a re-export so existing v1 callers don't churn.
+- **Why drop the `gh` username cache from the original draft (the refactor
+ commit)?** `gh api user --jq .login` is cheap and the call only happens
+ on workspace-create / settings open. The cache added complexity (state,
+ invalidation when the user re-auths) without measurable wins. Both the
+ settings preview and the per-create resolution call it directly.
+- **Why prefix only "new" branches?** Re-prefixing existing branches or PR
+ checkouts would rename other people's work and break remotes. v1's rule
+ was the right one; we kept it.
+- **Why per-project override at all?** A user working in monorepo-A and
+ open-source-B may want `kho/` in one and no prefix in the other. The
+ cascade (project → host → `none`) lets the global default stay useful
+ even when one project opts out.
+
+## Tests
+
+`workspace-creation/utils/branch-prefix.test.ts` covers the resolver:
+- mode cascading (project beats global; null project falls back to global;
+ both empty → `undefined`)
+- per-mode resolution (`author`, `github`, `custom`)
+- the collision guard (prefix equals existing branch name → undefined)
+- `gh` / `git user.name` lookup failure paths (return `null`, don't throw)
+
+## Out of scope / follow-ups
+
+See `.spec/improvements/SUPER-794/follow-ups.md`. Not addressed here:
+- Migration path for v1 users who already had a configured prefix in the
+ desktop local DB. Currently they reconfigure once in v2.
+- Org-level / team-level defaults (cloud-pushed). Host-local was the
+ product decision for the ticket; org defaults would layer on top of the
+ same cascade.
+- Prefix display in the workspace list (currently only visible when you
+ expand a workspace and see the branch).
diff --git a/plans/v2-branch-prefixes.md b/plans/v2-branch-prefixes.md
new file mode 100644
index 00000000000..41dfa308702
--- /dev/null
+++ b/plans/v2-branch-prefixes.md
@@ -0,0 +1,45 @@
+# V2 configurable branch prefixes (SUPER-835)
+
+Brings back v1's "branch prefix" feature for the v2 (host-service) workspace
+flow. Requested by Censys for their org-wide v2 trial.
+
+## Behaviour (v1 parity)
+
+- Modes: `none`, `github` (GitHub username), `author` (git `user.name`),
+ `custom` (free string).
+- Two levels: a host-wide global default plus an optional per-project
+ override. Project override wins when its mode is set; otherwise the global
+ default applies; otherwise `none`.
+- The resolved prefix is prepended as a path segment: `prefix/branch-name`.
+- Applied to **new** branches only — auto-generated names (AI / friendly
+ random) and user-typed branch names that don't already exist. Existing
+ branches and PR checkouts are never re-prefixed.
+- Collision guard: if the prefix equals an existing branch name, it's dropped
+ (git can't have both `censys` and `censys/foo`).
+
+## Where it lives
+
+v1 stored this in the desktop's local SQLite. v2 runs workspace creation in
+`packages/host-service`, which has its own SQLite DB — so storage is
+host-local there too (per the product decision for this ticket).
+
+## Changes
+
+1. **`@superset/shared/workspace-launch/branch.ts`** — export
+ `BRANCH_PREFIX_MODES` + `BranchPrefixMode` (host-service can't depend on
+ `@superset/local-db`). `resolveBranchPrefix` already lived here.
+2. **host-service DB** (`packages/host-service/src/db/schema.ts`) —
+ `projects.branchPrefixMode` / `branchPrefixCustom` columns + a single-row
+ `host_settings` table for the global default. New drizzle migration
+ (auto-applied on startup).
+3. **host-service `branch-prefix.ts` util** — git author / GitHub username
+ lookups + `resolveProjectBranchPrefix` (cascade + collision guard).
+4. **host-service `settings` router** — `getBranchPrefix`, `setBranchPrefix`,
+ `getGitInfo` (for the UI preview).
+5. **host-service `project` router** — `get` returns the prefix columns;
+ new `setBranchPrefix` mutation.
+6. **host-service `workspaces.create`** — applies the resolved prefix when
+ deriving a new branch name.
+7. **Renderer** — `V2GitSettings` (global default, on the existing
+ `/settings/git` route) and a `BranchPrefixSection` in `V2ProjectSettings`
+ (per-project override with a "use global default" option).