From 16f7120ac1ec7c2e42a51b92579c251d544f2d02 Mon Sep 17 00:00:00 2001 From: karlo Date: Tue, 16 Jun 2026 11:22:22 -0700 Subject: [PATCH 1/2] feat(settings): configurable branch name prefix The branch name for new tasks was hardcoded with the `posthog-code/` prefix. This adds a "Branch name prefix" setting (Settings -> Worktrees) so it can be changed or removed. - New `branchPrefix` setting, defaulting to the existing `posthog-code/`. - `deriveBranchName` / `suggestBranchName` take an optional `prefix` (default unchanged), so existing callers are unaffected. - `getSuggestedBranchName` reads the setting, so both worktree and cloud branch suggestions honor it. - The prefix is validated before saving (rejects invalid git names and a leading dash); empty means no prefix. Closes #1632 Generated-By: PostHog Code Task-Id: 8e9f327d-84b1-4608-9f48-c3038dbf87ca --- .../src/git-interaction/branchName.test.ts | 35 +++++++++++++++ .../core/src/git-interaction/branchName.ts | 25 +++++++++-- .../git-interaction/deriveBranchName.test.ts | 16 +++++++ .../utils/getSuggestedBranchName.ts | 7 ++- .../sections/worktrees/WorktreesSettings.tsx | 44 ++++++++++++++++++- .../ui/src/features/settings/settingsStore.ts | 17 ++++++- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts index b6247967d9..f407198959 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,38 @@ describe("suggestBranchName", () => { ]), ).toBe("posthog-code/fix-bug-4"); }); + + it("supports a custom prefix", () => { + expect(suggestBranchName("Fix bug", "abc", [], "team/")).toBe( + "team/fix-bug", + ); + expect(suggestBranchName("Fix bug", "abc", ["team/fix-bug"], "team/")).toBe( + "team/fix-bug-2", + ); + }); +}); + +describe("validateBranchPrefix", () => { + it("allows an empty prefix (no prefix)", () => { + expect(validateBranchPrefix("")).toBeNull(); + }); + + it("allows normal prefixes", () => { + expect(validateBranchPrefix("team/")).toBeNull(); + expect(validateBranchPrefix("posthog-code/")).toBeNull(); + }); + + it("rejects a leading dash (flag-like)", () => { + expect(validateBranchPrefix("-x/")).toBe( + "Branch prefix cannot start with a dash.", + ); + }); + + it("rejects spaces", () => { + expect(validateBranchPrefix("my team/")).not.toBeNull(); + }); + + it('rejects ".."', () => { + expect(validateBranchPrefix("../")).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..0efc25a281 100644 --- a/packages/core/src/git-interaction/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -48,4 +48,20 @@ describe("deriveBranchName", () => { "posthog-code/task-abc123", ); }); + + it("uses a custom prefix when provided", () => { + expect(deriveBranchName("Fix login bug", "abc123", "team/")).toBe( + "team/fix-login-bug", + ); + }); + + it("supports an empty prefix", () => { + expect(deriveBranchName("Fix login bug", "abc123", "")).toBe( + "fix-login-bug", + ); + }); + + it("applies a custom prefix to the task-ID fallback", () => { + expect(deriveBranchName("", "abc123", "team/")).toBe("team/task-abc123"); + }); }); 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, From 2b29791fce665fea8c726ee221b53f5b0cf20297 Mon Sep 17 00:00:00 2001 From: karlo Date: Tue, 16 Jun 2026 12:26:58 -0700 Subject: [PATCH 2/2] test: parameterise branch-name tests with it.each Addresses review feedback: use it.each for validateBranchPrefix, the deriveBranchName custom-prefix cases, and suggestBranchName, matching the parameterised-test convention in AGENTS.md. Generated-By: PostHog Code Task-Id: 8e9f327d-84b1-4608-9f48-c3038dbf87ca --- .../src/git-interaction/branchName.test.ts | 56 +++++++++---------- .../git-interaction/deriveBranchName.test.ts | 25 ++++----- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts index f407198959..c5fe4a0355 100644 --- a/packages/core/src/git-interaction/branchName.test.ts +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -114,37 +114,33 @@ describe("suggestBranchName", () => { ).toBe("posthog-code/fix-bug-4"); }); - it("supports a custom prefix", () => { - expect(suggestBranchName("Fix bug", "abc", [], "team/")).toBe( - "team/fix-bug", - ); - expect(suggestBranchName("Fix bug", "abc", ["team/fix-bug"], "team/")).toBe( - "team/fix-bug-2", - ); - }); + 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("allows an empty prefix (no prefix)", () => { - expect(validateBranchPrefix("")).toBeNull(); - }); - - it("allows normal prefixes", () => { - expect(validateBranchPrefix("team/")).toBeNull(); - expect(validateBranchPrefix("posthog-code/")).toBeNull(); - }); - - it("rejects a leading dash (flag-like)", () => { - expect(validateBranchPrefix("-x/")).toBe( - "Branch prefix cannot start with a dash.", - ); - }); - - it("rejects spaces", () => { - expect(validateBranchPrefix("my team/")).not.toBeNull(); - }); - - it('rejects ".."', () => { - expect(validateBranchPrefix("../")).not.toBeNull(); - }); + 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/deriveBranchName.test.ts b/packages/core/src/git-interaction/deriveBranchName.test.ts index 0efc25a281..482107b3d3 100644 --- a/packages/core/src/git-interaction/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -49,19 +49,14 @@ describe("deriveBranchName", () => { ); }); - it("uses a custom prefix when provided", () => { - expect(deriveBranchName("Fix login bug", "abc123", "team/")).toBe( - "team/fix-login-bug", - ); - }); - - it("supports an empty prefix", () => { - expect(deriveBranchName("Fix login bug", "abc123", "")).toBe( - "fix-login-bug", - ); - }); - - it("applies a custom prefix to the task-ID fallback", () => { - expect(deriveBranchName("", "abc123", "team/")).toBe("team/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); + }, + ); });