diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8766e8cb76f6..8e4463f1293c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -150,6 +150,7 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly worktreeBranchPrefix?: string; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -413,7 +414,13 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerSettingsService.layerTest( + input?.worktreeBranchPrefix === undefined + ? {} + : { worktreeBranchPrefix: input.worktreeBranchPrefix }, + ), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -1457,59 +1464,72 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Reconnect spinner resume bug"); }); - it("generates a worktree branch name for the first turn", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; + it.each([ + { + prefix: undefined, + generated: "feature/gpt-5-6-luna", + expected: "t3code/feature/gpt-5-6-luna", + }, + { + prefix: "my-team", + generated: "My-Team/feature/gpt-5-6-luna", + expected: "my-team/feature/gpt-5-6-luna", + }, + { + prefix: "t3code/my-team", + generated: "t3code/my-team/feature/gpt-5-6-luna", + expected: "t3code/my-team/feature/gpt-5-6-luna", + }, + { prefix: "", generated: "feature/gpt-5-6-luna", expected: "feature/gpt-5-6-luna" }, + ])( + "generates a worktree branch name with prefix $prefix", + async ({ prefix, generated, expected }) => { + const harness = await createHarness( + prefix === undefined ? undefined : { worktreeBranchPrefix: prefix }, + ); + const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-branch"), - threadId: ThreadId.make("thread-1"), - branch: "t3code/1234abcd", - worktreePath: "/tmp/provider-project-worktree", - }), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-branch"), + threadId: ThreadId.make("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project-worktree", + }), + ); - harness.generateBranchName.mockImplementation((input: unknown) => - Effect.succeed({ - branch: - typeof input === "object" && - input !== null && - "modelSelection" in input && - typeof input.modelSelection === "object" && - input.modelSelection !== null && - "model" in input.modelSelection && - typeof input.modelSelection.model === "string" - ? `feature/${input.modelSelection.model}` - : "feature/generated", - }), - ); + harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: generated })); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-branch-model"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-branch-model"), - role: "user", - text: "Add a safer reconnect backoff.", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-branch-model"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-branch-model"), + role: "user", + text: "Add a safer reconnect backoff.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); - await waitFor(() => harness.refreshStatus.mock.calls.length === 1); - expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ - message: "Add a safer reconnect backoff.", - }); - expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); - }); + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.refreshStatus.mock.calls.length === 1); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + message: "Add a safer reconnect backoff.", + }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + oldBranch: "t3code/1234abcd", + newBranch: expected, + }); + }, + ); it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..b0cde80f7d10 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,7 +12,11 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { + isTemporaryWorktreeBranch, + sanitizeBranchFragment, + WORKTREE_BRANCH_PREFIX, +} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -275,27 +279,22 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, prefix: string): string { const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) - : normalized; - - const branchFragment = withoutPrefix - .replace(/[^a-z0-9/_-]+/g, "-") - .replace(/\/+/g, "/") - .replace(/-+/g, "-") - .replace(/^[./_-]+|[./_-]+$/g, "") - .slice(0, 64) - .replace(/[./_-]+$/g, ""); + const withoutPrefix = + prefix.length > 0 && normalized.startsWith(`${prefix}/`) + ? normalized.slice(`${prefix}/`.length) + : normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) + ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + : normalized; - const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + const safeFragment = sanitizeBranchFragment(withoutPrefix); + return prefix.length > 0 ? `${prefix}/${safeFragment}` : safeFragment; } const make = Effect.gen(function* () { @@ -818,7 +817,10 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName( + generated.branch, + settings.worktreeBranchPrefix, + ); if (targetBranch === oldBranch) return; const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 582dc9f6cb94..de02e91558b4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -37,6 +37,7 @@ import { } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; +import { sanitizeBranchFragment } from "@t3tools/shared/git"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; @@ -523,6 +524,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix + ? ["Worktree branch prefix"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -556,6 +560,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeBranchPrefix, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -662,6 +667,7 @@ export function useSettingsRestore(onRestored?: () => void) { providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -2261,6 +2267,41 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + { + const rawPrefix = next.trim(); + const worktreeBranchPrefix = /[a-z0-9]/i.test(rawPrefix) + ? sanitizeBranchFragment(rawPrefix) + : ""; + if (worktreeBranchPrefix !== settings.worktreeBranchPrefix) { + updateSettings({ worktreeBranchPrefix }); + } + }} + /> + } + /> + { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults the worktree branch prefix and accepts an empty prefix", () => { + expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code"); + expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "" }).worktreeBranchPrefix).toBe(""); + expect(() => decodeServerSettingsPatch({ worktreeBranchPrefix: "/.." })).toThrow(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ba4facaf53ce..b7a159e1e4d6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -277,6 +277,17 @@ const makeBinaryPathSetting = (fallback: string) => Schema.withDecodingDefault(Effect.succeed(fallback)), ); +const WorktreeBranchPrefix = TrimmedString.check( + Schema.makeFilter( + (value) => + value.length === 0 || + (value.length <= 64 && + /^[a-z0-9](?:[a-z0-9/_-]*[a-z0-9])?$/.test(value) && + !value.includes("//")) || + "must be an empty string or a normalized Git branch prefix", + ), +); + export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; export interface ProviderSettingsFormAnnotation { @@ -638,6 +649,9 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + worktreeBranchPrefix: WorktreeBranchPrefix.pipe( + Schema.withDecodingDefault(Effect.succeed("t3code")), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -837,6 +851,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeBranchPrefix: Schema.optionalKey(WorktreeBranchPrefix), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey(