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).