Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<Select
value={selectValue}
onValueChange={handleModeChange}
disabled={disabled}
>
<SelectTrigger className={showDefault ? "w-[200px]" : "w-[180px]"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(labels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectValue === "custom" && (
<Input
placeholder="Prefix"
value={customPrefixInput}
onChange={(e) => setCustomPrefixInput(e.target.value)}
onBlur={handleCustomPrefixBlur}
className="w-[120px]"
disabled={disabled}
/>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { BranchPrefixControlMode } from "./BranchPrefixControl";
export { BranchPrefixControl } from "./BranchPrefixControl";
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-6 max-w-4xl w-full">
<div className="mb-8">
<h2 className="text-xl font-semibold">Git &amp; worktrees</h2>
<p className="text-sm text-muted-foreground mt-1">
Comment on lines +89 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Clearing the custom prefix persists mode:"custom" with a null value

When the user deletes the text in the custom-prefix input and blurs, sanitizeSegment("") returns "", which is then coerced to null by sanitized || null. The mutation saves { mode: "custom", customPrefix: null }. resolveBranchPrefix returns null for this state (customPrefix || null), so no prefix is applied — but the Select still shows "Custom". A user who clears the field expecting "Custom" to stop applying a prefix will see the correct preview (just branch-name) but the mode label is misleading. The same pattern is present in BranchPrefixSection.tsx. Switching mode back to "none" when the sanitized value is empty would make the saved state match the user's intent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/settings/git/components/V2GitSettings/V2GitSettings.tsx
Line: 113-117

Comment:
**Clearing the custom prefix persists `mode:"custom"` with a null value**

When the user deletes the text in the custom-prefix input and blurs, `sanitizeSegment("")` returns `""`, which is then coerced to `null` by `sanitized || null`. The mutation saves `{ mode: "custom", customPrefix: null }`. `resolveBranchPrefix` returns `null` for this state (`customPrefix || null`), so no prefix is applied — but the Select still shows "Custom". A user who clears the field expecting "Custom" to stop applying a prefix will see the correct preview (just `branch-name`) but the mode label is misleading. The same pattern is present in `BranchPrefixSection.tsx`. Switching `mode` back to `"none"` when the sanitized value is empty would make the saved state match the user's intent.

How can I resolve this? If you propose a fix, please make it concise.

Configure git branch behavior for new workspaces
</p>
</div>

<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Branch prefix</Label>
<p className="text-xs text-muted-foreground">
Group new branches under a folder. Projects can override this.{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
{previewPrefix ? `${previewPrefix}/branch-name` : "branch-name"}
</code>
</p>
</div>
<BranchPrefixControl
mode={mode}
customPrefix={customPrefix}
disabled={controlsDisabled}
onChange={(next) =>
// Host-wide control never produces null mode (no "default" option).
setMutation.mutate({
mode: next.mode ?? "none",
customPrefix: next.customPrefix,
})
}
/>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { V2GitSettings } from "./V2GitSettings";
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,5 +24,9 @@ function GitSettingsPage() {
[searchQuery, isV2CloudEnabled],
);

if (isV2CloudEnabled) {
return <V2GitSettings />;
}

return <GitSettings visibleItems={visibleItems} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export const SETTING_ITEM_VARIANT: Record<SettingItemId, SettingVariant> = {
[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",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -203,6 +204,20 @@ export function V2ProjectSettings({
currentRepoCloneUrl={project.repoCloneUrl}
/>
</SettingsRow>
{targetHostUrl && hostProject && (
<SettingsRow
label="Branch prefix"
hint="Namespace new branches for this project. Defaults to the host-wide Git setting."
>
<BranchPrefixSection
projectId={projectId}
hostUrl={targetHostUrl}
mode={hostProject.branchPrefixMode ?? null}
customPrefix={hostProject.branchPrefixCustom ?? null}
onChanged={() => refetchHostProject()}
/>
</SettingsRow>
)}
</section>

<section>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<BranchPrefixControl
mode={mode}
customPrefix={customPrefix}
showDefault
disabled={setMutation.isPending}
onChange={(next) => setMutation.mutate(next)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BranchPrefixSection } from "./BranchPrefixSection";
8 changes: 8 additions & 0 deletions packages/host-service/drizzle/0005_branch_prefix_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE `host_settings` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`branch_prefix_mode` text,
`branch_prefix_custom` text
);
Comment on lines +1 to +5
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce host_settings invariants at the DB layer.

host_settings is modeled as a single-row enum-backed table, but this migration currently permits any id and any mode string. A stray insert/update can make settings reads nondeterministic or return unsupported mode values.

Suggested migration hardening
 CREATE TABLE `host_settings` (
-	`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
-	`branch_prefix_mode` text,
+	`id` integer PRIMARY KEY DEFAULT 1 NOT NULL CHECK (`id` = 1),
+	`branch_prefix_mode` text CHECK (
+		`branch_prefix_mode` IN ('none', 'github', 'author', 'custom')
+		OR `branch_prefix_mode` IS NULL
+	),
 	`branch_prefix_custom` text
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE TABLE `host_settings` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`branch_prefix_mode` text,
`branch_prefix_custom` text
);
CREATE TABLE `host_settings` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL CHECK (`id` = 1),
`branch_prefix_mode` text CHECK (
`branch_prefix_mode` IN ('none', 'github', 'author', 'custom')
OR `branch_prefix_mode` IS NULL
),
`branch_prefix_custom` text
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/drizzle/0005_branch_prefix_settings.sql` around lines 1
- 5, The migration currently allows any id and any mode string; change the
host_settings table to enforce a single-row enum-backed record by adding a CHECK
constraint that id = 1 (to guarantee single-row semantics), make
branch_prefix_mode NOT NULL with a CHECK limiting values to the supported enum
(e.g., CHECK(branch_prefix_mode IN ('off','include','prefix'))) and set a
sensible DEFAULT (e.g., 'off'), and tighten branch_prefix_custom (NOT NULL
DEFAULT '' and optionally a conditional CHECK such as CHECK(branch_prefix_mode
<> 'prefix' OR (branch_prefix_custom IS NOT NULL AND branch_prefix_custom <>
'')) ) so invalid combinations are prevented; apply these changes to the CREATE
TABLE for host_settings and any related INSERT/UPDATE paths that assume those
defaults.

--> statement-breakpoint
ALTER TABLE `projects` ADD `branch_prefix_mode` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `branch_prefix_custom` text;
Loading
Loading