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 0000000000..c6c0484c39
--- /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 0000000000..ffe87e68ce
--- /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/git/components/V2GitSettings/V2GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx
new file mode 100644
index 0000000000..bfec7e2914
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx
@@ -0,0 +1,123 @@
+import {
+ type BranchPrefixMode,
+ resolveBranchPrefix,
+} from "@superset/shared/workspace-launch";
+import { Label } from "@superset/ui/label";
+import { toast } from "@superset/ui/sonner";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
+import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable";
+import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
+import { BranchPrefixControl } from "../../../components/BranchPrefixControl";
+
+const BRANCH_PREFIX_QUERY_KEY = ["host-branch-prefix"] as const;
+
+/**
+ * v2 Git settings — the host-wide branch-prefix default. Projects without
+ * their own override inherit this. The v1 equivalent lives in `GitSettings`.
+ */
+export function V2GitSettings() {
+ const hostService = useLocalHostService();
+ const { activeHostUrl } = hostService;
+ const queryClient = useQueryClient();
+
+ const branchPrefixQuery = useQuery({
+ queryKey: [...BRANCH_PREFIX_QUERY_KEY, activeHostUrl] as const,
+ enabled: !!activeHostUrl,
+ queryFn: () => {
+ if (!activeHostUrl) throw new Error("Host service unavailable");
+ return getHostServiceClientByUrl(
+ activeHostUrl,
+ ).settings.branchPrefix.get.query();
+ },
+ });
+
+ const gitInfoQuery = useQuery({
+ queryKey: ["host-git-info", activeHostUrl] as const,
+ enabled: !!activeHostUrl,
+ staleTime: 5 * 60 * 1000,
+ queryFn: () => {
+ if (!activeHostUrl) throw new Error("Host service unavailable");
+ return getHostServiceClientByUrl(
+ activeHostUrl,
+ ).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 (!activeHostUrl) {
+ throw new Error(
+ getHostServiceUnavailableMessage(hostService, {
+ action: "update the branch prefix",
+ }),
+ );
+ }
+ return getHostServiceClientByUrl(
+ activeHostUrl,
+ ).settings.branchPrefix.set.mutate(vars);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: BRANCH_PREFIX_QUERY_KEY,
+ });
+ },
+ 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 =
+ !activeHostUrl || branchPrefixQuery.isLoading || setMutation.isPending;
+
+ return (
+
+
+
Git & worktrees
+
+ Configure git branch behavior for new workspaces
+
+
+
+
+
+
+
+ Group new branches under a folder. Projects can override this.{" "}
+
+ {previewPrefix ? `${previewPrefix}/branch-name` : "branch-name"}
+
+
+
+
+ // Host-wide control never produces null mode (no "default" option).
+ setMutation.mutate({
+ mode: next.mode ?? "none",
+ customPrefix: next.customPrefix,
+ })
+ }
+ />
+
+
+ );
+}
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 0000000000..5443e5f911
--- /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 8e9bbc0e3f..8a9246fe56 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/page.tsx
@@ -4,6 +4,7 @@ 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,
@@ -23,5 +24,9 @@ function GitSettingsPage() {
[searchQuery, isV2CloudEnabled],
);
+ if (isV2CloudEnabled) {
+ return ;
+ }
+
return ;
}
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 204a8b21ca..6da5b899f4 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
@@ -136,7 +136,8 @@ 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",
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 d236f20265..bc98853ba0 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
@@ -16,6 +16,7 @@ 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 { BranchPrefixSection } from "./components/BranchPrefixSection";
import { DeleteProjectSection } from "./components/DeleteProjectSection";
import { IconUploadField } from "./components/IconUploadField";
import { NameSection } from "./components/NameSection";
@@ -203,6 +204,20 @@ export function V2ProjectSettings({
currentRepoCloneUrl={project.repoCloneUrl}
/>
+ {targetHostUrl && hostProject && (
+
+ refetchHostProject()}
+ />
+
+ )}
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 0000000000..bee986a0cb
--- /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 0000000000..7a69965a79
--- /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/packages/host-service/drizzle/0005_branch_prefix_settings.sql b/packages/host-service/drizzle/0005_branch_prefix_settings.sql
new file mode 100644
index 0000000000..efbf8c64b1
--- /dev/null
+++ b/packages/host-service/drizzle/0005_branch_prefix_settings.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `host_settings` (
+ `id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
+ `branch_prefix_mode` text,
+ `branch_prefix_custom` 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 0000000000..90b0e87b91
--- /dev/null
+++ b/packages/host-service/drizzle/meta/0005_snapshot.json
@@ -0,0 +1,637 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "b53b5c2b-8076-4d38-8cce-116ba9e5b35b",
+ "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
+ },
+ "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
+ },
+ "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 055ebf3d6a..c5f505edef 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": 1779410818930,
+ "tag": "0005_branch_prefix_settings",
+ "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 ed12e47044..9471127ecf 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,10 @@ export const projects = sqliteTable(
repoName: text("repo_name"),
repoUrl: text("repo_url"),
remoteName: text("remote_name"),
+ // 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 +51,17 @@ 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; this table holds the global 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),
+ 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 1a41d1b354..85f92e6bd5 100644
--- a/packages/host-service/src/trpc/router/project/project.ts
+++ b/packages/host-service/src/trpc/router/project/project.ts
@@ -3,6 +3,7 @@ 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";
@@ -50,6 +51,8 @@ export const projectRouter = router({
repoOwner: projects.repoOwner,
repoName: projects.repoName,
repoUrl: projects.repoUrl,
+ branchPrefixMode: projects.branchPrefixMode,
+ branchPrefixCustom: projects.branchPrefixCustom,
})
.from(projects)
.where(eq(projects.id, input.projectId))
@@ -57,6 +60,37 @@ export const projectRouter = router({
);
}),
+ /**
+ * 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 0000000000..1c9fb49d10
--- /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 18b91e7321..2fac6a3ad1 100644
--- a/packages/host-service/src/trpc/router/settings/index.ts
+++ b/packages/host-service/src/trpc/router/settings/index.ts
@@ -1,8 +1,10 @@
import { router } from "../../index";
import { agentConfigsRouter } from "./agent-configs";
+import { branchPrefixRouter } from "./branch-prefix";
export const settingsRouter = router({
agentConfigs: agentConfigsRouter,
+ branchPrefix: branchPrefixRouter,
});
export type { HostAgentConfig } from "./agent-configs";
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 0000000000..1940a66f17
--- /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 0000000000..0b564378c9
--- /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 fdd55e983c..25afc374c4 100644
--- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts
+++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts
@@ -32,6 +32,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 {
@@ -828,12 +829,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 +866,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,
diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts
index 0a2b200fd6..c2060863e3 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 cc993440ea..cd6d447536 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.md b/plans/v2-branch-prefixes.md
new file mode 100644
index 0000000000..41dfa30870
--- /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).