From f1ac515af74f674e345da180dc77b312c970de8a Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 11:53:34 -0400 Subject: [PATCH 1/6] feat(settings): configurable branch prefix for PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Branch prefix" setting (Settings → General → Version control) so the posthog-code/ default can be customized. - New branchPrefix setting (default "posthog-code/"), persisted, with a normalizeBranchPrefix() helper in @posthog/shared - UI branch-name suggestions use the configured prefix - Local agent runs: prefix threaded through sessionService → agent.start / reconnect → workspace-server system prompt + Claude adapter branch-naming - Cloud runs: AgentServerConfig.branchPrefix + --branchPrefix CLI flag; cloud system prompt uses it; desktop sends branch_prefix in the create-run payload - Tests for the normalizer and custom-prefix branch derivation Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- .../agent/src/adapters/claude/claude-agent.ts | 5 +- .../adapters/claude/session/instructions.ts | 14 ++++- .../src/adapters/claude/session/options.ts | 10 ++-- packages/agent/src/adapters/claude/types.ts | 2 + .../agent/src/server/agent-server.test.ts | 9 +++ packages/agent/src/server/agent-server.ts | 9 ++- packages/agent/src/server/bin.ts | 5 ++ packages/agent/src/server/types.ts | 2 + packages/api-client/src/posthog-client.ts | 7 +++ .../src/git-interaction/branchName.test.ts | 9 +++ .../core/src/git-interaction/branchName.ts | 13 +++-- .../git-interaction/deriveBranchName.test.ts | 10 ++++ packages/core/src/sessions/sessionService.ts | 14 ++++- .../src/task-detail/taskCreationApiClient.ts | 2 + .../core/src/task-detail/taskCreationSaga.ts | 1 + packages/core/src/task-detail/taskInput.ts | 2 + packages/shared/src/git-naming.test.ts | 30 ++++++++++ packages/shared/src/git-naming.ts | 15 +++++ packages/shared/src/task-creation-domain.ts | 2 + .../utils/getSuggestedBranchName.ts | 10 +++- .../settings/sections/GeneralSettings.tsx | 56 ++++++++++++++++++- .../ui/src/features/settings/settingsStore.ts | 17 +++++- .../task-detail/hooks/useTaskCreation.ts | 1 + .../src/services/agent/agent.ts | 13 ++++- .../src/services/agent/schemas.ts | 4 ++ 25 files changed, 239 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/git-naming.test.ts diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7c6d8220f4..86b22b8a5e 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1682,7 +1682,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ...initialInProcess, }; - const systemPrompt = buildSystemPrompt(meta?.systemPrompt); + const systemPrompt = buildSystemPrompt( + meta?.systemPrompt, + meta?.branchPrefix, + ); if (meta?.mcpToolApprovals) { setMcpToolApprovalStates(meta.mcpToolApprovals); diff --git a/packages/agent/src/adapters/claude/session/instructions.ts b/packages/agent/src/adapters/claude/session/instructions.ts index 792be41d04..d9b532a8a3 100644 --- a/packages/agent/src/adapters/claude/session/instructions.ts +++ b/packages/agent/src/adapters/claude/session/instructions.ts @@ -1,9 +1,11 @@ -const BRANCH_NAMING = ` +import { BRANCH_PREFIX, normalizeBranchPrefix } from "@posthog/shared"; + +const buildBranchNaming = (prefix: string) => ` # Branch Naming When working in a detached HEAD state, create a descriptive branch name based on the work being done before committing. Do this automatically without asking the user. -When creating a new branch, prefix it with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). +When creating a new branch, prefix it with \`${prefix}\` (e.g. \`${prefix}fix-login-redirect\`). `; const PLAN_MODE = ` @@ -26,4 +28,10 @@ If an MCP tool call is explicitly denied with a message, relay that denial messa If an MCP tool call returns an error, treat it as a normal tool error — troubleshoot, retry, or inform the user about the specific error. Do NOT assume it is a permissions issue and do NOT direct the user to any settings page. `; -export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS; +export const buildAppendedInstructions = (branchPrefix?: string | null) => + buildBranchNaming(normalizeBranchPrefix(branchPrefix)) + + PLAN_MODE + + MCP_TOOLS; + +/** Default appended instructions using the standard {@link BRANCH_PREFIX}. */ +export const APPENDED_INSTRUCTIONS = buildAppendedInstructions(BRANCH_PREFIX); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index ece3813296..8db90f4e09 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -26,7 +26,7 @@ import { } from "../hooks"; import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; -import { APPENDED_INSTRUCTIONS } from "./instructions"; +import { buildAppendedInstructions } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; import { DEFAULT_MODEL } from "./models"; import type { SettingsManager } from "./settings"; @@ -74,11 +74,13 @@ export interface BuildOptionsParams { export function buildSystemPrompt( customPrompt?: unknown, + branchPrefix?: string | null, ): Options["systemPrompt"] { + const appended = buildAppendedInstructions(branchPrefix); const defaultPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code", - append: APPENDED_INSTRUCTIONS, + append: appended, }; if (!customPrompt) { @@ -86,7 +88,7 @@ export function buildSystemPrompt( } if (typeof customPrompt === "string") { - return customPrompt + APPENDED_INSTRUCTIONS; + return customPrompt + appended; } if ( @@ -97,7 +99,7 @@ export function buildSystemPrompt( ) { return { ...defaultPrompt, - append: customPrompt.append + APPENDED_INSTRUCTIONS, + append: customPrompt.append + appended, }; } diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..d988f827d3 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -169,6 +169,8 @@ export type NewSessionMeta = { model?: string; /** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */ baseBranch?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; /** * Repo-less channel "generic chat box" session: enables the lazy-repo tools * (list_repos / clone_repo) and channel guidance. The agent decides at diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index e7030e7b18..1ab6496368 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1406,6 +1406,15 @@ describe("AgentServer HTTP Mode", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); + it("uses a configured branch prefix in the cloud prompt", () => { + process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "signal_report"; + const s = createServer({ branchPrefix: "team/" }); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(); + expect(prompt).toContain("team/fix-login-redirect"); + expect(prompt).not.toContain("posthog-code/fix-login-redirect"); + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + }); + it("returns PR-update prompt for existing PRs on Slack-origin runs", () => { process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack"; const s = createServer(); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 5b01dc9968..27ff5b04c9 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -14,6 +14,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { normalizeBranchPrefix } from "@posthog/shared"; import { Hono } from "hono"; import { z } from "zod"; import packageJson from "../../package.json" with { type: "json" }; @@ -1071,6 +1072,9 @@ export class AgentServer { jsonSchema: preTask?.json_schema ?? null, permissionMode: initialPermissionMode, ...(this.config.baseBranch && { baseBranch: this.config.baseBranch }), + ...(this.config.branchPrefix && { + branchPrefix: this.config.branchPrefix, + }), ...this.buildClaudeCodeSessionMeta(runtimeAdapter), }, }); @@ -1736,6 +1740,7 @@ export class AgentServer { slackThreadUrl?: string | null, ): string { const taskId = this.config.taskId; + const branchPrefix = normalizeBranchPrefix(this.config.branchPrefix); const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges(); const isSlack = this.getCloudInteractionOrigin() === "slack"; const identityInstructions = isSlack @@ -1764,7 +1769,7 @@ Commits MUST be signed. \`git commit\` and \`git push\` are blocked in this envi To commit: stage your changes with \`git add\`, then call the \`git_signed_commit\` tool (full name \`${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}\`) with a \`message\` (and optional \`body\`/\`paths\`). It creates a GitHub-signed ("Verified") commit on the branch and keeps your local checkout in -sync. To start a new branch, pass \`branch\` (prefixed with \`posthog-code/\`) — the tool creates +sync. To start a new branch, pass \`branch\` (prefixed with \`${branchPrefix}\`) — the tool creates it on the remote for you. ## Updating from the base branch @@ -1901,7 +1906,7 @@ ${signedCommitInstructions} # Cloud Task Execution After completing the requested changes: -1. Pick a new branch name prefixed with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`) +1. Pick a new branch name prefixed with \`${branchPrefix}\` (e.g. \`${branchPrefix}fix-login-redirect\`) 2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with \`branch\` set to that name and a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). The tool creates the branch on the remote and a signed commit on it. 3. Before opening the PR, prepare the body: - Keep the PR description brief overall. Summarize only the most important changes — do NOT enumerate every change you made. A few sentences or bullets is plenty. diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index c70368ae9a..a61fd33d89 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -97,6 +97,10 @@ program ) .option("--createPr ", "Whether this run may publish changes") .option("--baseBranch ", "Base branch for PR creation") + .option( + "--branchPrefix ", + 'Prefix for branches the agent creates (default "posthog-code/")', + ) .option( "--claudeCodeConfig ", "Claude Code config as JSON (systemPrompt, systemPromptAppend, plugins)", @@ -170,6 +174,7 @@ program createPr, mcpServers, baseBranch: options.baseBranch, + branchPrefix: options.branchPrefix, claudeCode, allowedDomains, runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index d11cb7748c..256391a5e2 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -24,6 +24,8 @@ export interface AgentServerConfig { version?: string; mcpServers?: RemoteMcpServer[]; baseBranch?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; claudeCode?: ClaudeCodeConfig; allowedDomains?: string[]; runtimeAdapter?: "claude" | "codex"; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 249701fa40..13ab708638 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -8,6 +8,7 @@ import type { StoredLogEntry, } from "@posthog/shared"; import { + BRANCH_PREFIX, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, SEAT_PRODUCT_KEY, @@ -465,6 +466,8 @@ interface CloudRunOptions { adapter?: CloudRuntimeAdapter; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; @@ -552,6 +555,10 @@ function buildCloudRunRequestBody( if (options?.homeQuickAction) { body.home_quick_action = options.homeQuickAction; } + // Only send a customized prefix; the backend defaults to "posthog-code/". + if (options?.branchPrefix && options.branchPrefix !== BRANCH_PREFIX) { + body.branch_prefix = options.branchPrefix; + } return body; } diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts index b6247967d9..c7f163beae 100644 --- a/packages/core/src/git-interaction/branchName.test.ts +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -112,4 +112,13 @@ describe("suggestBranchName", () => { ]), ).toBe("posthog-code/fix-bug-4"); }); + + it("uses a custom prefix and dedupes against it", () => { + expect(suggestBranchName("Fix bug", "abc", [], "team/")).toBe( + "team/fix-bug", + ); + expect(suggestBranchName("Fix bug", "abc", ["team/fix-bug"], "team/")).toBe( + "team/fix-bug-2", + ); + }); }); diff --git a/packages/core/src/git-interaction/branchName.ts b/packages/core/src/git-interaction/branchName.ts index d5301081eb..11700453ff 100644 --- a/packages/core/src/git-interaction/branchName.ts +++ b/packages/core/src/git-interaction/branchName.ts @@ -54,7 +54,11 @@ export function validateBranchName(name: string): string | null { return null; } -export function deriveBranchName(title: string, fallbackId: string): string { +export function deriveBranchName( + title: string, + fallbackId: string, + prefix: string = BRANCH_PREFIX, +): string { const slug = title .toLowerCase() .trim() @@ -64,16 +68,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..319f651a7b 100644 --- a/packages/core/src/git-interaction/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -48,4 +48,14 @@ describe("deriveBranchName", () => { "posthog-code/task-abc123", ); }); + + it("applies a custom prefix when provided", () => { + expect(deriveBranchName("Fix login bug", "abc123", "team/")).toBe( + "team/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/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 6672cbbaa3..fdcaa50c47 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -253,7 +253,10 @@ export interface SessionServiceDeps { setAdapter(taskRunId: string, adapter: Adapter): void; removeAdapter(taskRunId: string): void; }; - readonly settings: { customInstructions?: string | null }; + readonly settings: { + customInstructions?: string | null; + branchPrefix?: string | null; + }; usageLimit: { show: (...args: any[]) => any }; readonly addDirectoryDialog: { open: boolean }; taskViewedApi: { markActivity(taskId: string): void }; @@ -817,7 +820,7 @@ export class SessionService { this.d.log.warn("Failed to verify workspace", { taskId, err }); }); - const { customInstructions } = this.d.settings; + const { customInstructions, branchPrefix } = this.d.settings; const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, @@ -830,6 +833,7 @@ export class SessionService { permissionMode: persistedMode, model: persistedModel, customInstructions: customInstructions || undefined, + branchPrefix: branchPrefix || undefined, }); if (result) { @@ -1129,7 +1133,10 @@ export class SessionService { throw new Error("Failed to create task run. Please try again."); } - const { customInstructions: startCustomInstructions } = this.d.settings; + const { + customInstructions: startCustomInstructions, + branchPrefix: startBranchPrefix, + } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; const result = await this.d.trpc.agent.start.mutate({ taskId, @@ -1140,6 +1147,7 @@ export class SessionService { permissionMode: executionMode, adapter, customInstructions: startCustomInstructions || undefined, + branchPrefix: startBranchPrefix || undefined, effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) : undefined, diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 3ef2f5ed56..d86e28d6d9 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -8,6 +8,8 @@ export interface CreateTaskRunClientOptions { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index fef5e75dc0..0580332082 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -285,6 +285,7 @@ export class TaskCreationSaga extends Saga< adapter: input.adapter, model: input.model, reasoningLevel: input.reasoningLevel, + branchPrefix: input.branchPrefix, sandboxEnvironmentId: input.sandboxEnvironmentId, prAuthorshipMode, runSource: input.cloudRunSource ?? "manual", diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index a8ea7bbc7e..201a8b9227 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -14,6 +14,7 @@ export interface PrepareTaskInputOptions { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + branchPrefix?: string; environmentId?: string | null; sandboxEnvironmentId?: string; signalReportId?: string; @@ -46,6 +47,7 @@ export function prepareTaskInput( adapter: options.adapter, model: options.model, reasoningLevel: options.reasoningLevel, + branchPrefix: options.branchPrefix, environmentId: options.environmentId ?? undefined, sandboxEnvironmentId: options.sandboxEnvironmentId, cloudPrAuthorshipMode: diff --git a/packages/shared/src/git-naming.test.ts b/packages/shared/src/git-naming.test.ts new file mode 100644 index 0000000000..76d1c27c49 --- /dev/null +++ b/packages/shared/src/git-naming.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { BRANCH_PREFIX, normalizeBranchPrefix } from "./git-naming"; + +describe("normalizeBranchPrefix", () => { + it("falls back to the default for empty input", () => { + expect(normalizeBranchPrefix("")).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(" ")).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(undefined)).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(null)).toBe(BRANCH_PREFIX); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeBranchPrefix(" team/ ")).toBe("team/"); + }); + + it("preserves a trailing slash or dash verbatim", () => { + expect(normalizeBranchPrefix("team/")).toBe("team/"); + expect(normalizeBranchPrefix("team-")).toBe("team-"); + expect(normalizeBranchPrefix("team")).toBe("team"); + }); + + it("strips leading slashes and collapses repeated slashes", () => { + expect(normalizeBranchPrefix("/team/")).toBe("team/"); + expect(normalizeBranchPrefix("team//sub/")).toBe("team/sub/"); + }); + + it("falls back to the default when only slashes are provided", () => { + expect(normalizeBranchPrefix("/")).toBe(BRANCH_PREFIX); + }); +}); diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts index 480f9d398b..8e96083ad0 100644 --- a/packages/shared/src/git-naming.ts +++ b/packages/shared/src/git-naming.ts @@ -1 +1,16 @@ +/** Default prefix applied to branches PostHog Code creates. */ export const BRANCH_PREFIX = "posthog-code/"; + +/** + * Normalize a user-provided branch prefix into a safe value, falling back to + * {@link BRANCH_PREFIX} when empty. Strips leading slashes and collapses + * repeated slashes (git refs cannot start with `/` or contain `//`). The + * prefix is used verbatim in front of the generated slug, so a trailing `/` + * (e.g. `team/`) or a trailing dash (e.g. `team-`) is preserved as typed. + */ +export function normalizeBranchPrefix(input?: string | null): string { + const trimmed = (input ?? "").trim(); + if (trimmed === "") return BRANCH_PREFIX; + const cleaned = trimmed.replace(/^\/+/, "").replace(/\/{2,}/g, "/"); + return cleaned === "" ? BRANCH_PREFIX : cleaned; +} diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 93001c0d53..1d2e45be94 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -29,6 +29,8 @@ export interface TaskCreationInput { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; environmentId?: string; sandboxEnvironmentId?: string; cloudPrAuthorshipMode?: PrAuthorshipMode; diff --git a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts index 5940e5ce99..3a481a7f71 100644 --- a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts +++ b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts @@ -2,7 +2,9 @@ import { deriveBranchName, suggestBranchName, } from "@posthog/core/git-interaction/branchName"; +import { normalizeBranchPrefix } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import type { QueryClient } from "@tanstack/react-query"; import type { GitCacheKeyProvider } from "../gitCacheProvider"; @@ -24,12 +26,16 @@ export function getSuggestedBranchName( ? String(task.task_number) : (task?.slug ?? taskId); - if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId); + const prefix = normalizeBranchPrefix( + 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/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9702e59a72..3d52c1c585 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -1,7 +1,11 @@ import { ArrowSquareOut } from "@phosphor-icons/react"; import { buildPostHogUrl } from "@posthog/core/settings/posthogUrl"; import { useHostTRPC } from "@posthog/host-router/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + BRANCH_PREFIX, + normalizeBranchPrefix, +} from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { COLLAPSE_MODE_OPTIONS, @@ -18,6 +22,7 @@ import { type SendMessagesWith, useSettingsStore, } from "@posthog/ui/features/settings/settingsStore"; +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; import { track } from "@posthog/ui/shell/analytics"; import type { ThemePreference } from "@posthog/ui/shell/themeStore"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; @@ -30,9 +35,10 @@ import { Slider, Switch, Text, + TextField, } from "@radix-ui/themes"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; export function GeneralSettings() { @@ -102,8 +108,29 @@ export function GeneralSettings() { setSendMessagesWith, setConversationCollapseMode, setHedgehogMode, + branchPrefix, + setBranchPrefix, } = useSettingsStore(); + // Branch prefix uses a local draft committed on debounce, so typing stays + // responsive and we only normalize/persist once the user pauses. + const [draftBranchPrefix, setDraftBranchPrefix] = useState(branchPrefix); + const debouncedBranchPrefix = useDebounce(draftBranchPrefix, 500); + + useEffect(() => { + setDraftBranchPrefix(branchPrefix); + }, [branchPrefix]); + + useEffect(() => { + const normalized = normalizeBranchPrefix(debouncedBranchPrefix); + if (normalized === branchPrefix) return; + setBranchPrefix(normalized); + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "branch_prefix", + new_value: normalized !== BRANCH_PREFIX, + }); + }, [debouncedBranchPrefix, branchPrefix, setBranchPrefix]); + // Sync toggle off if the user denied notification permission at the OS level useEffect(() => { if (window.Notification?.permission === "denied" && desktopNotifications) { @@ -519,6 +546,31 @@ export function GeneralSettings() { + {/* Version control */} + + Version control + + + + + setDraftBranchPrefix(e.target.value)} + placeholder={BRANCH_PREFIX} + size="1" + className="min-w-[240px]" + spellCheck={false} + /> + + e.g. {normalizeBranchPrefix(draftBranchPrefix)}fix-login-redirect + + + + {/* Editor */} Editor diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index cfdf5c8757..ccde26a0e2 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,5 +1,9 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; -import type { ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import { + BRANCH_PREFIX, + type ExecutionMode, + type WorkspaceMode, +} from "@posthog/shared"; import { COLLAPSE_MODE_DEFAULT, type CollapseMode, @@ -118,6 +122,10 @@ interface SettingsStore { diffOpenMode: DiffOpenMode; setDiffOpenMode: (mode: DiffOpenMode) => void; + // Version control + branchPrefix: string; + setBranchPrefix: (prefix: string) => void; + // System / power / permissions allowBypassPermissions: boolean; preventSleepWhileRunning: boolean; @@ -235,6 +243,10 @@ export const useSettingsStore = create()( diffOpenMode: "auto", setDiffOpenMode: (mode) => set({ diffOpenMode: mode }), + // Version control + branchPrefix: BRANCH_PREFIX, + setBranchPrefix: (prefix) => set({ branchPrefix: prefix }), + // System / power / permissions allowBypassPermissions: false, preventSleepWhileRunning: false, @@ -333,6 +345,9 @@ export const useSettingsStore = create()( // Diff viewer diffOpenMode: state.diffOpenMode, + // Version control + branchPrefix: state.branchPrefix, + // System / power / permissions allowBypassPermissions: state.allowBypassPermissions, preventSleepWhileRunning: state.preventSleepWhileRunning, diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index c990e71361..80e3f4a4ca 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -271,6 +271,7 @@ export function useTaskCreation({ adapter, model, reasoningLevel, + branchPrefix: useSettingsStore.getState().branchPrefix, environmentId, sandboxEnvironmentId, signalReportId, diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index ebf83a9d3a..ecad6220f2 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -60,6 +60,7 @@ import { import { type AcpMessage, isAuthError, + normalizeBranchPrefix, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -264,6 +265,8 @@ interface SessionConfig { permissionMode?: string; /** Custom instructions injected into the system prompt */ customInstructions?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; /** Replaces the PostHog system prompt entirely (constrained surfaces). */ systemPromptOverride?: string; /** Tool names denied for this session (passed to the Claude SDK). */ @@ -541,6 +544,7 @@ export class AgentService extends TypedEventEmitter { systemPromptOverride?: string, channelMode?: boolean, knownLocalFolders?: RegisteredFolder[], + branchPrefix?: string, ): { append: string; } { @@ -550,6 +554,8 @@ export class AgentService extends TypedEventEmitter { return { append: systemPromptOverride }; } + const prefix = normalizeBranchPrefix(branchPrefix); + let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; prompt += ` @@ -572,7 +578,7 @@ EOF )" \`\`\` -When creating new branches, prefix them with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). +When creating new branches, prefix them with \`${prefix}\` (e.g. \`${prefix}fix-login-redirect\`). When creating pull requests, add the following footer at the end of the PR description: \`\`\` @@ -662,6 +668,7 @@ If a repository IS genuinely required, attach one in this priority order: adapter, permissionMode, customInstructions, + branchPrefix, systemPromptOverride, disallowedTools, effort, @@ -745,6 +752,7 @@ If a repository IS genuinely required, attach one in this priority order: systemPromptOverride, channelMode, knownLocalFolders, + branchPrefix, ); const bundledSkillsDir = join( @@ -943,6 +951,7 @@ If a repository IS genuinely required, attach one in this priority order: sessionId: existingSessionId, systemPrompt, ...(channelMode && { channelMode }), + ...(branchPrefix && { branchPrefix }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -969,6 +978,7 @@ If a repository IS genuinely required, attach one in this priority order: environment: "local", systemPrompt, ...(channelMode && { channelMode }), + ...(branchPrefix && { branchPrefix }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -1844,6 +1854,7 @@ For git operations while detached: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: "customInstructions" in params ? params.customInstructions : undefined, + branchPrefix: "branchPrefix" in params ? params.branchPrefix : undefined, systemPromptOverride: "systemPromptOverride" in params ? params.systemPromptOverride diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 5cfb8cd010..725bf990bc 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -48,6 +48,8 @@ export const startSessionInput = z.object({ adapter: z.enum(["claude", "codex"]).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix: z.string().max(100).optional(), /** * Replaces the PostHog system prompt entirely for this session. Used by * constrained, single-purpose surfaces (e.g. the canvas generator) that drive @@ -187,6 +189,8 @@ export const reconnectSessionInput = z.object({ permissionMode: z.string().optional(), model: z.string().optional(), customInstructions: z.string().max(2000).optional(), + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix: z.string().max(100).optional(), effort: effortLevelSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), }); From 4e7d87accb09c18badc5c816aa44d9f0dc7ff0f7 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 13:15:30 -0400 Subject: [PATCH 2/6] fix(settings): validate and bound the branch prefix input Address review feedback on the branch-prefix setting: - Validate the prefix as it is actually used (in front of a slug) with validateBranchName, show an inline error, and never persist a value that would yield an invalid git ref. This guards the agent prompt and git_signed_commit paths, which have no downstream sanitization. - Add maxLength=100 to the input to match the workspace-server schema cap, so a long prefix can no longer break session start/reconnect with a ZodError. Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- .../settings/sections/GeneralSettings.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 3d52c1c585..5584c90f25 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -1,4 +1,5 @@ import { ArrowSquareOut } from "@phosphor-icons/react"; +import { validateBranchName } from "@posthog/core/git-interaction/branchName"; import { buildPostHogUrl } from "@posthog/core/settings/posthogUrl"; import { useHostTRPC } from "@posthog/host-router/react"; import { @@ -116,6 +117,13 @@ export function GeneralSettings() { // responsive and we only normalize/persist once the user pauses. const [draftBranchPrefix, setDraftBranchPrefix] = useState(branchPrefix); const debouncedBranchPrefix = useDebounce(draftBranchPrefix, 500); + const normalizedDraftPrefix = normalizeBranchPrefix(draftBranchPrefix); + // Validate the prefix the way it is actually used — in front of a slug — so a + // value like "my team/" (space) or "feat/." is rejected before it can reach + // the agent prompt or a git_signed_commit call and produce an invalid ref. + const branchPrefixError = validateBranchName( + `${normalizedDraftPrefix}example`, + ); useEffect(() => { setDraftBranchPrefix(branchPrefix); @@ -124,6 +132,9 @@ export function GeneralSettings() { useEffect(() => { const normalized = normalizeBranchPrefix(debouncedBranchPrefix); if (normalized === branchPrefix) return; + // Never persist a prefix that would produce an invalid branch name; the + // workspace-server schema also caps length at 100. + if (validateBranchName(`${normalized}example`) !== null) return; setBranchPrefix(normalized); track(ANALYTICS_EVENTS.SETTING_CHANGED, { setting_name: "branch_prefix", @@ -562,12 +573,20 @@ export function GeneralSettings() { onChange={(e) => setDraftBranchPrefix(e.target.value)} placeholder={BRANCH_PREFIX} size="1" + maxLength={100} className="min-w-[240px]" spellCheck={false} + color={branchPrefixError ? "red" : undefined} /> - - e.g. {normalizeBranchPrefix(draftBranchPrefix)}fix-login-redirect - + {branchPrefixError ? ( + + {branchPrefixError} + + ) : ( + + e.g. {normalizedDraftPrefix}fix-login-redirect + + )} From 8752b53dd755665cad79c4ae87d29dffb9beec22 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 13:29:25 -0400 Subject: [PATCH 3/6] fix(settings): always slash-terminate the branch prefix normalizeBranchPrefix now guarantees exactly one trailing slash so the prefix is always a slash-terminated namespace (e.g. "team" -> "team/"), avoiding a run-together branch name like "teamfix-login-bug". Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- packages/shared/src/git-naming.test.ts | 7 ++++--- packages/shared/src/git-naming.ts | 17 ++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/git-naming.test.ts b/packages/shared/src/git-naming.test.ts index 76d1c27c49..00fa3e6419 100644 --- a/packages/shared/src/git-naming.test.ts +++ b/packages/shared/src/git-naming.test.ts @@ -13,10 +13,11 @@ describe("normalizeBranchPrefix", () => { expect(normalizeBranchPrefix(" team/ ")).toBe("team/"); }); - it("preserves a trailing slash or dash verbatim", () => { + it("guarantees exactly one trailing slash", () => { + expect(normalizeBranchPrefix("team")).toBe("team/"); expect(normalizeBranchPrefix("team/")).toBe("team/"); - expect(normalizeBranchPrefix("team-")).toBe("team-"); - expect(normalizeBranchPrefix("team")).toBe("team"); + expect(normalizeBranchPrefix("team//")).toBe("team/"); + expect(normalizeBranchPrefix("team-")).toBe("team-/"); }); it("strips leading slashes and collapses repeated slashes", () => { diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts index 8e96083ad0..91a73b5237 100644 --- a/packages/shared/src/git-naming.ts +++ b/packages/shared/src/git-naming.ts @@ -2,15 +2,18 @@ export const BRANCH_PREFIX = "posthog-code/"; /** - * Normalize a user-provided branch prefix into a safe value, falling back to - * {@link BRANCH_PREFIX} when empty. Strips leading slashes and collapses - * repeated slashes (git refs cannot start with `/` or contain `//`). The - * prefix is used verbatim in front of the generated slug, so a trailing `/` - * (e.g. `team/`) or a trailing dash (e.g. `team-`) is preserved as typed. + * Normalize a user-provided branch prefix into a safe, slash-terminated value, + * falling back to {@link BRANCH_PREFIX} when empty. Strips leading slashes and + * collapses repeated slashes (git refs cannot start with `/` or contain `//`), + * then guarantees exactly one trailing `/` so the prefix is always a + * slash-terminated namespace (`team` → `team/`). */ export function normalizeBranchPrefix(input?: string | null): string { const trimmed = (input ?? "").trim(); if (trimmed === "") return BRANCH_PREFIX; - const cleaned = trimmed.replace(/^\/+/, "").replace(/\/{2,}/g, "/"); - return cleaned === "" ? BRANCH_PREFIX : cleaned; + const cleaned = trimmed + .replace(/^\/+/, "") + .replace(/\/{2,}/g, "/") + .replace(/\/+$/, ""); + return cleaned === "" ? BRANCH_PREFIX : `${cleaned}/`; } From ab59542375168b2d4ee47142297ce8b6db47e4f5 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 13:44:20 -0400 Subject: [PATCH 4/6] fix(settings): tighten branch prefix cap to 40 chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100 was an arbitrary guard, not a considered limit. A prefix is just a namespace (a GitHub username maxes at 39 chars, so `username/` fits in 40), while the descriptive slug is capped separately at 60 — so 40 keeps the full ref well under git's ~250-byte limit. Introduce a single MAX_BRANCH_PREFIX_LENGTH constant in @posthog/shared and use it for both the UI input maxLength and the workspace-server start/reconnect schemas, so they can no longer drift apart. Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- packages/shared/src/git-naming.ts | 8 ++++++++ .../ui/src/features/settings/sections/GeneralSettings.tsx | 3 ++- packages/workspace-server/src/services/agent/schemas.ts | 5 +++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts index 91a73b5237..0ce6992613 100644 --- a/packages/shared/src/git-naming.ts +++ b/packages/shared/src/git-naming.ts @@ -1,6 +1,14 @@ /** Default prefix applied to branches PostHog Code creates. */ export const BRANCH_PREFIX = "posthog-code/"; +/** + * Max length of a custom branch prefix. A prefix is just a namespace (a GitHub + * username maxes at 39 chars, so `username/` fits in 40); the descriptive part + * of the branch is the slug, which is capped separately at 60. Keeps the full + * ref comfortably under git's ~250-byte limit. + */ +export const MAX_BRANCH_PREFIX_LENGTH = 40; + /** * Normalize a user-provided branch prefix into a safe, slash-terminated value, * falling back to {@link BRANCH_PREFIX} when empty. Strips leading slashes and diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 5584c90f25..1f17442785 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -5,6 +5,7 @@ import { useHostTRPC } from "@posthog/host-router/react"; import { ANALYTICS_EVENTS, BRANCH_PREFIX, + MAX_BRANCH_PREFIX_LENGTH, normalizeBranchPrefix, } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; @@ -573,7 +574,7 @@ export function GeneralSettings() { onChange={(e) => setDraftBranchPrefix(e.target.value)} placeholder={BRANCH_PREFIX} size="1" - maxLength={100} + maxLength={MAX_BRANCH_PREFIX_LENGTH} className="min-w-[240px]" spellCheck={false} color={branchPrefixError ? "red" : undefined} diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 725bf990bc..e257d8732a 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -2,6 +2,7 @@ import type { RequestPermissionRequest, PermissionOption as SdkPermissionOption, } from "@agentclientprotocol/sdk"; +import { MAX_BRANCH_PREFIX_LENGTH } from "@posthog/shared"; import { effortLevelSchema } from "@posthog/shared/domain-types"; import { z } from "zod"; @@ -49,7 +50,7 @@ export const startSessionInput = z.object({ additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ - branchPrefix: z.string().max(100).optional(), + branchPrefix: z.string().max(MAX_BRANCH_PREFIX_LENGTH).optional(), /** * Replaces the PostHog system prompt entirely for this session. Used by * constrained, single-purpose surfaces (e.g. the canvas generator) that drive @@ -190,7 +191,7 @@ export const reconnectSessionInput = z.object({ model: z.string().optional(), customInstructions: z.string().max(2000).optional(), /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ - branchPrefix: z.string().max(100).optional(), + branchPrefix: z.string().max(MAX_BRANCH_PREFIX_LENGTH).optional(), effort: effortLevelSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), }); From 7cb12272394e31d2c37f629fcaab0b21123245ed Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 14:02:43 -0400 Subject: [PATCH 5/6] fix(shared): avoid ReDoS in normalizeBranchPrefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the slash-trimming regexes (/\/+$/ etc., flagged by CodeQL as a polynomial regular expression on uncontrolled input) with a linear split("/")/filter(Boolean)/join — identical behavior, no backtracking. Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- packages/shared/src/git-naming.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts index 0ce6992613..764c1be631 100644 --- a/packages/shared/src/git-naming.ts +++ b/packages/shared/src/git-naming.ts @@ -17,11 +17,9 @@ export const MAX_BRANCH_PREFIX_LENGTH = 40; * slash-terminated namespace (`team` → `team/`). */ export function normalizeBranchPrefix(input?: string | null): string { - const trimmed = (input ?? "").trim(); - if (trimmed === "") return BRANCH_PREFIX; - const cleaned = trimmed - .replace(/^\/+/, "") - .replace(/\/{2,}/g, "/") - .replace(/\/+$/, ""); - return cleaned === "" ? BRANCH_PREFIX : `${cleaned}/`; + // Split on "/" and drop empty segments — this strips leading/trailing slashes + // and collapses repeats in linear time, avoiding the backtracking a regex + // like /\/+$/ would incur on adversarial input (CodeQL ReDoS). + const segments = (input ?? "").trim().split("/").filter(Boolean); + return segments.length === 0 ? BRANCH_PREFIX : `${segments.join("/")}/`; } From 717d8cc0fd482a46dda4178745f999d8cf723fda Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Tue, 23 Jun 2026 14:02:54 -0400 Subject: [PATCH 6/6] fix(settings): enforce valid branch prefix at the store setter - setBranchPrefix now normalizes and rejects any prefix that would yield an invalid git ref, so the stored value is always valid regardless of caller (the agent and cloud paths read it verbatim). validateBranchName owns the rules; @posthog/shared's normalizeBranchPrefix stays format-only because shared cannot depend on core. - Commit the prefix on blur instead of via a debounced effect, dropping the useDebounce dependency. The remaining effect only mirrors async persisted-state hydration back into the editable draft. Generated-By: PostHog Code Task-Id: f55fc9d7-90a0-4dd8-adb9-5763d04cea62 --- .../settings/sections/GeneralSettings.tsx | 24 ++++++++++--------- .../ui/src/features/settings/settingsStore.ts | 13 +++++++++- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 1f17442785..f23aef2055 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -24,7 +24,6 @@ import { type SendMessagesWith, useSettingsStore, } from "@posthog/ui/features/settings/settingsStore"; -import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; import { track } from "@posthog/ui/shell/analytics"; import type { ThemePreference } from "@posthog/ui/shell/themeStore"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; @@ -114,34 +113,36 @@ export function GeneralSettings() { setBranchPrefix, } = useSettingsStore(); - // Branch prefix uses a local draft committed on debounce, so typing stays - // responsive and we only normalize/persist once the user pauses. + // The input holds a raw draft so typing stays responsive (no normalize/snap + // mid-keystroke); it's committed on blur. The preview/error are derived, not + // stored. const [draftBranchPrefix, setDraftBranchPrefix] = useState(branchPrefix); - const debouncedBranchPrefix = useDebounce(draftBranchPrefix, 500); const normalizedDraftPrefix = normalizeBranchPrefix(draftBranchPrefix); // Validate the prefix the way it is actually used — in front of a slug — so a - // value like "my team/" (space) or "feat/." is rejected before it can reach - // the agent prompt or a git_signed_commit call and produce an invalid ref. + // value like "my team/" (space) or "feat/." surfaces an error before it can + // reach the agent prompt or a git_signed_commit call. const branchPrefixError = validateBranchName( `${normalizedDraftPrefix}example`, ); + // Mirror external store changes (async persisted-state hydration, or the + // setter normalizing the value) back into the editable draft. useEffect(() => { setDraftBranchPrefix(branchPrefix); }, [branchPrefix]); - useEffect(() => { - const normalized = normalizeBranchPrefix(debouncedBranchPrefix); + const commitBranchPrefix = useCallback(() => { + const normalized = normalizeBranchPrefix(draftBranchPrefix); if (normalized === branchPrefix) return; - // Never persist a prefix that would produce an invalid branch name; the - // workspace-server schema also caps length at 100. + // The store setter rejects invalid prefixes; skip the no-op + analytics + // when we already know it won't take. if (validateBranchName(`${normalized}example`) !== null) return; setBranchPrefix(normalized); track(ANALYTICS_EVENTS.SETTING_CHANGED, { setting_name: "branch_prefix", new_value: normalized !== BRANCH_PREFIX, }); - }, [debouncedBranchPrefix, branchPrefix, setBranchPrefix]); + }, [draftBranchPrefix, branchPrefix, setBranchPrefix]); // Sync toggle off if the user denied notification permission at the OS level useEffect(() => { @@ -572,6 +573,7 @@ export function GeneralSettings() { setDraftBranchPrefix(e.target.value)} + onBlur={commitBranchPrefix} placeholder={BRANCH_PREFIX} size="1" maxLength={MAX_BRANCH_PREFIX_LENGTH} diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index ccde26a0e2..030b5195dc 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,7 +1,9 @@ +import { validateBranchName } from "@posthog/core/git-interaction/branchName"; import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; import { BRANCH_PREFIX, type ExecutionMode, + normalizeBranchPrefix, type WorkspaceMode, } from "@posthog/shared"; import { @@ -245,7 +247,16 @@ export const useSettingsStore = create()( // Version control branchPrefix: BRANCH_PREFIX, - setBranchPrefix: (prefix) => set({ branchPrefix: prefix }), + // Self-enforcing on write: normalize the format and reject anything that + // would yield an invalid git ref, so no caller can persist a bad prefix + // (the agent and cloud paths read this value verbatim). validateBranchName + // (in @posthog/core) owns the ref rules; @posthog/shared's + // normalizeBranchPrefix is format-only since shared can't depend on core. + setBranchPrefix: (prefix) => { + const normalized = normalizeBranchPrefix(prefix); + if (validateBranchName(`${normalized}x`) !== null) return; + set({ branchPrefix: normalized }); + }, // System / power / permissions allowBypassPermissions: false,