From 9321eb07429e0770287ad4f8aca045babd77a2d9 Mon Sep 17 00:00:00 2001 From: RaitP1 Date: Sat, 15 Aug 2026 02:34:45 +0300 Subject: [PATCH 1/3] feat(server): use a configurable prefix for generated worktree branches --- .../Layers/ProviderCommandReactor.test.ts | 32 ++++++------ .../Layers/ProviderCommandReactor.ts | 30 ++--------- .../components/settings/SettingsPanels.tsx | 35 +++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ packages/contracts/src/settings.test.ts | 11 ++++ packages/contracts/src/settings.ts | 2 + packages/shared/src/git.test.ts | 50 +++++++++++++++++++ packages/shared/src/git.ts | 31 ++++++++++++ 8 files changed, 156 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..7c769ee30833 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -145,6 +145,7 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; + readonly worktreeBranchPrefix?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; readonly requiresNewThreadForModelChange?: boolean; @@ -412,7 +413,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), ); @@ -1456,8 +1463,8 @@ 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(); + it("generates a worktree branch name for the first turn using the configured prefix", async () => { + const harness = await createHarness({ worktreeBranchPrefix: "feat/" }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -1470,19 +1477,8 @@ describe("ProviderCommandReactor", () => { }), ); - 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.mockImplementation(() => + Effect.succeed({ branch: "Safer reconnect backoff" }), ); await Effect.runPromise( @@ -1507,6 +1503,10 @@ describe("ProviderCommandReactor", () => { expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + oldBranch: "t3code/1234abcd", + newBranch: "feat/safer-reconnect-backoff", + }); expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..785020f78607 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,7 +12,7 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { buildGeneratedWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -275,29 +275,6 @@ 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 { - 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 safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; -} - const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; @@ -818,7 +795,10 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName( + settings.worktreeBranchPrefix, + generated.branch, + ); 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 d57a4da1c2f0..2e3f3a56fa87 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -34,6 +34,7 @@ import { MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { sanitizeBranchPrefix } from "@t3tools/shared/git"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -517,6 +518,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"] : []), @@ -540,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeBranchPrefix, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -645,6 +650,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, @@ -2159,6 +2165,35 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + + updateSettings({ worktreeBranchPrefix: sanitizeBranchPrefix(next) }) + } + placeholder="t3code/" + spellCheck={false} + aria-label="Worktree branch prefix" + /> + } + /> + { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults the branch prefix to the built-in namespace for legacy configs", () => { + expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code/"); + }); + + it("accepts a custom branch prefix, including an empty one", () => { + expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "feat/" }).worktreeBranchPrefix).toBe( + "feat/", + ); + expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "" }).worktreeBranchPrefix).toBe(""); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..feabd6c635b3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -571,6 +571,7 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + worktreeBranchPrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code/"))), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -725,6 +726,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeBranchPrefix: Schema.optionalKey(TrimmedString), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..6e022f1d51df 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { applyGitStatusStreamEvent, + buildGeneratedWorktreeBranchName, buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, @@ -110,6 +111,55 @@ describe("isTemporaryWorktreeBranch", () => { }); }); +describe("buildGeneratedWorktreeBranchName", () => { + it("applies the default prefix", () => { + expect(buildGeneratedWorktreeBranchName("t3code/", "Multiselect Toggle")).toBe( + "t3code/multiselect-toggle", + ); + }); + + it("applies a custom prefix verbatim", () => { + expect(buildGeneratedWorktreeBranchName("feat/", "Multiselect Toggle")).toBe( + "feat/multiselect-toggle", + ); + expect(buildGeneratedWorktreeBranchName("wip-", "Multiselect Toggle")).toBe( + "wip-multiselect-toggle", + ); + }); + + it("produces a bare name for an empty prefix", () => { + expect(buildGeneratedWorktreeBranchName("", "Multiselect Toggle")).toBe("multiselect-toggle"); + }); + + it("strips a temporary-branch namespace the model echoed back", () => { + expect(buildGeneratedWorktreeBranchName("feat/", "t3code/multiselect-toggle")).toBe( + "feat/multiselect-toggle", + ); + }); + + it("strips the configured prefix the model echoed back", () => { + expect(buildGeneratedWorktreeBranchName("feat/", "feat/multiselect-toggle")).toBe( + "feat/multiselect-toggle", + ); + expect(buildGeneratedWorktreeBranchName("feat/", "refs/heads/feat/multiselect-toggle")).toBe( + "feat/multiselect-toggle", + ); + }); + + it("filters characters git rejects in a prefix", () => { + expect(buildGeneratedWorktreeBranchName("feat branch/", "multiselect toggle")).toBe( + "feat-branch/multiselect-toggle", + ); + expect(buildGeneratedWorktreeBranchName("//feat//", "multiselect toggle")).toBe( + "feat/multiselect-toggle", + ); + }); + + it("falls back to update when the generated name sanitizes to nothing", () => { + expect(buildGeneratedWorktreeBranchName("feat/", "!!!")).toBe("feat/update"); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: VcsStatusRemoteResult = { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..c79b2f33ddd5 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -108,6 +108,37 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } +export function sanitizeBranchPrefix(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/\/+/g, "/") + .replace(/^\/+/, "") + .slice(0, 64); +} + +function stripLeadingNamespace(value: string, namespace: string): string { + return namespace.length > 0 && value.startsWith(namespace) + ? value.slice(namespace.length) + : value; +} + +export function buildGeneratedWorktreeBranchName(prefix: string, raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + const safePrefix = sanitizeBranchPrefix(prefix); + const withoutEcho = stripLeadingNamespace( + stripLeadingNamespace(normalized, `${WORKTREE_BRANCH_PREFIX}/`), + safePrefix, + ); + return `${safePrefix}${sanitizeBranchFragment(withoutEcho)}`; +} + /** * Normalize a git remote URL into a stable comparison key. */ From f3cd2332a055b0432f56829036d85f57efaa441c Mon Sep 17 00:00:00 2001 From: RaitP1 Date: Sat, 15 Aug 2026 03:26:55 +0300 Subject: [PATCH 2/3] fix(shared): keep a separator-less branch prefix and drop a leading dash --- packages/shared/src/git.test.ts | 7 +++++++ packages/shared/src/git.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 6e022f1d51df..d96b677d0c7c 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -146,6 +146,10 @@ describe("buildGeneratedWorktreeBranchName", () => { ); }); + it("keeps a name that merely starts with a separator-less prefix", () => { + expect(buildGeneratedWorktreeBranchName("feat", "feature-x")).toBe("featfeature-x"); + }); + it("filters characters git rejects in a prefix", () => { expect(buildGeneratedWorktreeBranchName("feat branch/", "multiselect toggle")).toBe( "feat-branch/multiselect-toggle", @@ -153,6 +157,9 @@ describe("buildGeneratedWorktreeBranchName", () => { expect(buildGeneratedWorktreeBranchName("//feat//", "multiselect toggle")).toBe( "feat/multiselect-toggle", ); + expect(buildGeneratedWorktreeBranchName("-feat/", "multiselect toggle")).toBe( + "feat/multiselect-toggle", + ); }); it("falls back to update when the generated name sanitizes to nothing", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index c79b2f33ddd5..dd4b55aacd96 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -115,12 +115,12 @@ export function sanitizeBranchPrefix(raw: string): string { .replace(/[^a-z0-9/_-]+/g, "-") .replace(/-+/g, "-") .replace(/\/+/g, "/") - .replace(/^\/+/, "") + .replace(/^[-/]+/, "") .slice(0, 64); } function stripLeadingNamespace(value: string, namespace: string): string { - return namespace.length > 0 && value.startsWith(namespace) + return /[/_-]$/.test(namespace) && value.startsWith(namespace) ? value.slice(namespace.length) : value; } From 28986112aa9e8ec65de8c1b4c0e6e0f4b6c9c64a Mon Sep 17 00:00:00 2001 From: RaitP1 Date: Sat, 15 Aug 2026 03:26:56 +0300 Subject: [PATCH 3/3] fix(web): point the branch prefix search entry at its own row --- apps/web/src/components/settings/settingsSearch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 58475c30f1af..7f69675c03c2 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -143,7 +143,6 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "worktree-branch-prefix", title: "Worktree branch prefix", to: "/settings/general", - targetId: "new-threads", }, { id: "add-project-starts-in",