diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts index b6247967d9..c5fe4a0355 100644 --- a/packages/core/src/git-interaction/branchName.test.ts +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -3,6 +3,7 @@ import { sanitizeBranchName, suggestBranchName, validateBranchName, + validateBranchPrefix, } from "./branchName"; describe("sanitizeBranchName", () => { @@ -112,4 +113,34 @@ describe("suggestBranchName", () => { ]), ).toBe("posthog-code/fix-bug-4"); }); + + it.each([ + { existing: [] as string[], expected: "team/fix-bug" }, + { existing: ["team/fix-bug"], expected: "team/fix-bug-2" }, + ])( + "uses a custom prefix (existing $existing -> $expected)", + ({ existing, expected }) => { + expect(suggestBranchName("Fix bug", "abc", existing, "team/")).toBe( + expected, + ); + }, + ); +}); + +describe("validateBranchPrefix", () => { + it.each([ + { prefix: "", expected: null }, + { prefix: "team/", expected: null }, + { prefix: "posthog-code/", expected: null }, + { prefix: "-x/", expected: "Branch prefix cannot start with a dash." }, + ])("returns $expected for prefix $prefix", ({ prefix, expected }) => { + expect(validateBranchPrefix(prefix)).toBe(expected); + }); + + it.each(["my team/", "../", "~/"])( + "rejects an invalid prefix (%j)", + (prefix) => { + expect(validateBranchPrefix(prefix)).not.toBeNull(); + }, + ); }); diff --git a/packages/core/src/git-interaction/branchName.ts b/packages/core/src/git-interaction/branchName.ts index d5301081eb..aa498bf533 100644 --- a/packages/core/src/git-interaction/branchName.ts +++ b/packages/core/src/git-interaction/branchName.ts @@ -54,7 +54,23 @@ export function validateBranchName(name: string): string | null { return null; } -export function deriveBranchName(title: string, fallbackId: string): string { +// A user-configured branch prefix is prepended to generated branch names, so it +// must form a valid leading segment. Empty means "no prefix". We reject a +// leading dash defensively (git could read it as a flag) and otherwise validate +// it as the start of a real branch name. +export function validateBranchPrefix(prefix: string): string | null { + if (prefix === "") return null; + if (prefix.startsWith("-")) { + return "Branch prefix cannot start with a dash."; + } + return validateBranchName(`${prefix}example`); +} + +export function deriveBranchName( + title: string, + fallbackId: string, + prefix: string = BRANCH_PREFIX, +): string { const slug = title .toLowerCase() .trim() @@ -64,16 +80,17 @@ export function deriveBranchName(title: string, fallbackId: string): string { .slice(0, 60) .replace(/-$/, ""); - if (!slug) return `${BRANCH_PREFIX}task-${fallbackId}`; - return `${BRANCH_PREFIX}${slug}`; + if (!slug) return `${prefix}task-${fallbackId}`; + return `${prefix}${slug}`; } export function suggestBranchName( title: string, fallbackId: string, existingBranches: string[], + prefix: string = BRANCH_PREFIX, ): string { - const base = deriveBranchName(title, fallbackId); + const base = deriveBranchName(title, fallbackId, prefix); if (!existingBranches.includes(base)) return base; diff --git a/packages/core/src/git-interaction/deriveBranchName.test.ts b/packages/core/src/git-interaction/deriveBranchName.test.ts index 1d4cc0b860..482107b3d3 100644 --- a/packages/core/src/git-interaction/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -48,4 +48,15 @@ describe("deriveBranchName", () => { "posthog-code/task-abc123", ); }); + + it.each([ + { title: "Fix login bug", prefix: "team/", expected: "team/fix-login-bug" }, + { title: "Fix login bug", prefix: "", expected: "fix-login-bug" }, + { title: "", prefix: "team/", expected: "team/task-abc123" }, + ])( + "applies a custom prefix ($title, $prefix -> $expected)", + ({ title, prefix, expected }) => { + expect(deriveBranchName(title, "abc123", prefix)).toBe(expected); + }, + ); }); diff --git a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts index 5940e5ce99..786926618b 100644 --- a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts +++ b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts @@ -4,6 +4,7 @@ import { } from "@posthog/core/git-interaction/branchName"; import type { Task } from "@posthog/shared/domain-types"; import type { QueryClient } from "@tanstack/react-query"; +import { useSettingsStore } from "../../settings/settingsStore"; import type { GitCacheKeyProvider } from "../gitCacheProvider"; export function getSuggestedBranchName( @@ -24,12 +25,14 @@ export function getSuggestedBranchName( ? String(task.task_number) : (task?.slug ?? taskId); - if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId); + const prefix = useSettingsStore.getState().branchPrefix; + + if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId, prefix); const cached = queryClient.getQueryData( provider.gitQueryKey("getAllBranches", { directoryPath: repoPath }), ) ?? []; - return suggestBranchName(task?.title ?? "", fallbackId, cached); + return suggestBranchName(task?.title ?? "", fallbackId, cached, prefix); } diff --git a/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx index be36e6e81e..389b889064 100644 --- a/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx @@ -1,3 +1,4 @@ +import { validateBranchPrefix } from "@posthog/core/git-interaction/branchName"; import { buildTaskMap, groupWorktrees, @@ -7,7 +8,8 @@ import { deleteWorktree as runDeleteWorktree } from "@posthog/core/settings/work import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { Flex, Switch, Text, TextField } from "@radix-ui/themes"; import { useQueries, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDebounce } from "../../../../primitives/hooks/useDebounce"; import { toast } from "../../../../primitives/toast"; import { logger } from "../../../../shell/logger"; import { useFolders } from "../../../folders/useFolders"; @@ -16,6 +18,7 @@ import { useDeleteTask } from "../../../tasks/useTaskCrudMutations"; import { useTasks } from "../../../tasks/useTasks"; import { WORKSPACE_QUERY_KEY } from "../../../workspace/identifiers"; import { SettingRow } from "../../SettingRow"; +import { useSettingsStore } from "../../settingsStore"; import { WorktreeGroupSection } from "./WorktreeGroupSection"; const log = logger.scope("worktrees-settings"); @@ -33,6 +36,21 @@ export function WorktreesSettings() { const { folders } = useFolders(); const { data: tasks } = useTasks(); + const branchPrefix = useSettingsStore((s) => s.branchPrefix); + const setBranchPrefix = useSettingsStore((s) => s.setBranchPrefix); + const [draftBranchPrefix, setDraftBranchPrefix] = useState(branchPrefix); + const debouncedBranchPrefix = useDebounce(draftBranchPrefix, 500); + const branchPrefixError = validateBranchPrefix(draftBranchPrefix); + useEffect(() => { + setDraftBranchPrefix(branchPrefix); + }, [branchPrefix]); + useEffect(() => { + if (debouncedBranchPrefix === branchPrefix) return; + // Don't persist an invalid prefix; the inline error prompts a fix. + if (validateBranchPrefix(debouncedBranchPrefix) !== null) return; + setBranchPrefix(debouncedBranchPrefix); + }, [debouncedBranchPrefix, branchPrefix, setBranchPrefix]); + const worktreeQueries = useQueries({ queries: folders.map((folder) => ({ queryKey: trpc.workspace.listGitWorktrees.queryKey({ @@ -133,6 +151,30 @@ export function WorktreesSettings() { return ( + + + setDraftBranchPrefix(e.target.value)} + placeholder="posthog-code/" + size="1" + className="min-w-[200px]" + color={branchPrefixError ? "red" : undefined} + /> + {branchPrefixError ? ( + + {branchPrefixError} + + ) : ( + + Example: posthog-code/ + + )} + + void; + // Git / branches + branchPrefix: string; + setBranchPrefix: (value: string) => void; + // System / power / permissions allowBypassPermissions: boolean; preventSleepWhileRunning: boolean; @@ -221,6 +229,10 @@ export const useSettingsStore = create()( diffOpenMode: "auto", setDiffOpenMode: (mode) => set({ diffOpenMode: mode }), + // Git / branches + branchPrefix: BRANCH_PREFIX, + setBranchPrefix: (value) => set({ branchPrefix: value }), + // System / power / permissions allowBypassPermissions: false, preventSleepWhileRunning: false, @@ -317,6 +329,9 @@ export const useSettingsStore = create()( // Diff viewer diffOpenMode: state.diffOpenMode, + // Git / branches + branchPrefix: state.branchPrefix, + // System / power / permissions allowBypassPermissions: state.allowBypassPermissions, preventSleepWhileRunning: state.preventSleepWhileRunning,