From d5369a5367d98e92fc092bc1c6bfe4941f227009 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 20:48:39 +0100 Subject: [PATCH 1/6] fix: preserve configurable worktree branch prefixes --- .../Layers/ProviderCommandReactor.ts | 20 +++++++++----- apps/web/src/components/ChatView.logic.ts | 5 ++-- apps/web/src/components/ChatView.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 27 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 4 +++ packages/contracts/src/settings.test.ts | 21 +++++++++++++-- packages/contracts/src/settings.ts | 3 +++ 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7c522e5799..1912469453 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -71,8 +71,8 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +const TEMP_WORKTREE_BRANCH_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*\/[0-9a-f]{8}$/; function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); @@ -109,15 +109,20 @@ function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } -function buildGeneratedWorktreeBranchName(raw: string): string { +function extractWorktreeBranchPrefix(branch: string): string { + const slashIdx = branch.indexOf("/"); + return slashIdx > 0 ? branch.slice(0, slashIdx) : DEFAULT_WORKTREE_BRANCH_PREFIX; +} + +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) + const withoutPrefix = normalized.startsWith(`${prefix}/`) + ? normalized.slice(`${prefix}/`.length) : normalized; const branchFragment = withoutPrefix @@ -129,7 +134,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${prefix}/${safeFragment}`; } const make = Effect.gen(function* () { @@ -422,6 +427,7 @@ const make = Effect.gen(function* () { } const oldBranch = input.branch; + const branchPrefix = extractWorktreeBranchPrefix(oldBranch); const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { @@ -436,7 +442,7 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, branchPrefix); if (targetBranch === oldBranch) return; const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a27fb203e..bad3dfb7ee 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -10,7 +10,6 @@ import { } from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -99,10 +98,10 @@ export function readFileAsDataUrl(file: File): Promise { }); } -export function buildTemporaryWorktreeBranchName(): string { +export function buildTemporaryWorktreeBranchName(prefix: string): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${prefix}/${token}`; } export function cloneComposerImageForRetry( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..b2a787916b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2586,7 +2586,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); + const newBranch = buildTemporaryWorktreeBranchName(settings.worktreeBranchPrefix); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bb149c00a4..8cb318493e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -766,6 +766,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + updateSettings({ worktreeBranchPrefix: event.target.value })} + placeholder="t3code" + spellCheck={false} + /> + } + /> + { + const decode = Schema.decodeSync(ClientSettingsSchema); -describe("DEFAULT_CLIENT_SETTINGS", () => { it("includes archive confirmation with a false default", () => { expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false); }); + + it("defaults worktreeBranchPrefix to 't3code' when missing from persisted settings", () => { + const result = decode({}); + expect(result.worktreeBranchPrefix).toBe("t3code"); + }); + + it("preserves a custom worktreeBranchPrefix value", () => { + const result = decode({ worktreeBranchPrefix: "myteam" }); + expect(result.worktreeBranchPrefix).toBe("myteam"); + }); + + it("has 't3code' as the default in DEFAULT_CLIENT_SETTINGS", () => { + expect(DEFAULT_CLIENT_SETTINGS.worktreeBranchPrefix).toBe("t3code"); + }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..ceba6d3728 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -34,6 +34,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), + worktreeBranchPrefix: Schema.String.check(Schema.isMaxLength(64)).pipe( + Schema.withDecodingDefault(() => "t3code"), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; From c9b9a5cdd202d02a5c88aa9b3e5ffb75fc4160a2 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 20:56:34 +0100 Subject: [PATCH 2/6] fix: normalize worktree prefix matching --- .../Layers/ProviderCommandReactor.test.ts | 54 ++++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 5 +- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 834ab9be9e..25730d4353 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn(() => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -308,6 +308,58 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("preserves mixed-case worktree branch prefixes when renaming generated branches", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateBranchName.mockImplementationOnce(() => + Effect.succeed({ + branch: "T3code/Feature", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-update-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "T3code/abcd1234", + worktreePath: "/tmp/provider-project", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-rename-prefix"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-rename-prefix"), + role: "user", + text: "rename the branch", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-project", + oldBranch: "T3code/abcd1234", + newBranch: "T3code/feature", + }); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return thread?.branch === "T3code/feature"; + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1912469453..fd1e4f04d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -115,14 +115,15 @@ function extractWorktreeBranchPrefix(branch: string): string { } function buildGeneratedWorktreeBranchName(raw: string, prefix: string): string { + const normalizedPrefix = prefix.trim().toLowerCase().replace(/['"`]/g, ""); const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${prefix}/`) - ? normalized.slice(`${prefix}/`.length) + const withoutPrefix = normalized.startsWith(`${normalizedPrefix}/`) + ? normalized.slice(`${normalizedPrefix}/`.length) : normalized; const branchFragment = withoutPrefix From 8fdc315a544170257e96ea9a927f33ba0ac67bcf Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 21:03:31 +0100 Subject: [PATCH 3/6] fix: validate worktree branch prefixes --- .../Layers/ProviderCommandReactor.test.ts | 44 ++++++++++++++ .../Layers/ProviderCommandReactor.ts | 6 +- apps/web/src/components/ChatView.logic.ts | 4 +- .../components/settings/SettingsPanels.tsx | 59 +++++++++++++++++-- apps/web/src/hooks/useSettings.ts | 5 +- packages/contracts/src/settings.test.ts | 24 +++++++- packages/contracts/src/settings.ts | 35 ++++++++++- 7 files changed, 163 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 25730d4353..6b334b92c0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -360,6 +360,50 @@ describe("ProviderCommandReactor", () => { }); }); + it("renames temporary worktree branches that start with a digit prefix", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateBranchName.mockImplementationOnce(() => + Effect.succeed({ + branch: "3code/Feature", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-update-digit-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "3code/abcd1234", + worktreePath: "/tmp/provider-project", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-digit-prefix"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-digit-prefix"), + role: "user", + text: "rename the branch", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-project", + oldBranch: "3code/abcd1234", + newBranch: "3code/feature", + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fd1e4f04d3..289f3745fa 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,6 +11,7 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; +import { WORKTREE_BRANCH_PREFIX_PATTERN } from "@t3tools/contracts/settings"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -72,7 +73,10 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*\/[0-9a-f]{8}$/; +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( + `^${WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}\\/[0-9a-f]{8}$`, + "i", +); function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bad3dfb7ee..db42e26178 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,5 @@ import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; +import { WorktreeBranchPrefix } from "@t3tools/contracts/settings"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; @@ -100,8 +101,9 @@ export function readFileAsDataUrl(file: File): Promise { export function buildTemporaryWorktreeBranchName(prefix: string): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. + const resolvedPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix)(prefix); const token = randomUUID().slice(0, 8).toLowerCase(); - return `${prefix}/${token}`; + return `${resolvedPrefix}/${token}`; } export function cloneComposerImageForRetry( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8cb318493e..a5083e0bc4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -18,9 +18,14 @@ import { type ServerProviderModel, ThreadId, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_UNIFIED_SETTINGS, + DEFAULT_WORKTREE_BRANCH_PREFIX, + WorktreeBranchPrefix, + WORKTREE_BRANCH_PREFIX_PATTERN, +} from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { Equal } from "effect"; +import { Equal, Schema } from "effect"; import { APP_VERSION } from "../../branding"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -400,6 +405,10 @@ export function GeneralSettingsPanel() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [worktreeBranchPrefixInput, setWorktreeBranchPrefixInput] = useState( + settings.worktreeBranchPrefix, + ); + const [worktreeBranchPrefixError, setWorktreeBranchPrefixError] = useState(null); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const refreshingRef = useRef(false); const queryClient = useQueryClient(); @@ -424,6 +433,33 @@ export function GeneralSettingsPanel() { const availableEditors = serverConfigQuery.data?.availableEditors; const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; const codexHomePath = settings.providers.codex.homePath; + const saveWorktreeBranchPrefix = useCallback( + (rawValue: string) => { + const trimmed = rawValue.trim(); + if (trimmed.length === 0) { + setWorktreeBranchPrefixError(null); + setWorktreeBranchPrefixInput(DEFAULT_WORKTREE_BRANCH_PREFIX); + updateSettings({ worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX }); + return; + } + + if (!WORKTREE_BRANCH_PREFIX_PATTERN.test(trimmed)) { + setWorktreeBranchPrefixError("Use 1-64 letters, numbers, dots, hyphens, or underscores."); + return; + } + + const normalized = Schema.decodeUnknownSync(WorktreeBranchPrefix)(trimmed); + setWorktreeBranchPrefixError(null); + setWorktreeBranchPrefixInput(normalized); + updateSettings({ worktreeBranchPrefix: normalized }); + }, + [updateSettings], + ); + + useEffect(() => { + setWorktreeBranchPrefixInput(settings.worktreeBranchPrefix); + setWorktreeBranchPrefixError(null); + }, [settings.worktreeBranchPrefix]); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; @@ -768,7 +804,8 @@ export function GeneralSettingsPanel() { updateSettings({ worktreeBranchPrefix: event.target.value })} + value={worktreeBranchPrefixInput} + aria-invalid={worktreeBranchPrefixError ? true : undefined} + pattern={WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)} + title="Use 1-64 letters, numbers, dots, hyphens, or underscores." + onBlur={(event) => saveWorktreeBranchPrefix(event.target.value)} + onChange={(event) => { + setWorktreeBranchPrefixInput(event.target.value); + if ( + worktreeBranchPrefixError && + WORKTREE_BRANCH_PREFIX_PATTERN.test(event.target.value.trim()) + ) { + setWorktreeBranchPrefixError(null); + } + }} placeholder="t3code" spellCheck={false} /> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a0c9eea9b6..b1e163d9ae 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -24,6 +24,7 @@ import { ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, + WorktreeBranchPrefix, SidebarProjectSortOrder, SidebarThreadSortOrder, TimestampFormat, @@ -227,7 +228,9 @@ export function buildLegacyClientSettingsMigrationPatch( } if (typeof legacySettings.worktreeBranchPrefix === "string") { - patch.worktreeBranchPrefix = legacySettings.worktreeBranchPrefix; + patch.worktreeBranchPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix)( + legacySettings.worktreeBranchPrefix, + ); } return patch; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 9de8d2af3b..17dea30f02 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS } from "./settings"; +import { + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_WORKTREE_BRANCH_PREFIX, +} from "./settings"; describe("ClientSettingsSchema", () => { const decode = Schema.decodeSync(ClientSettingsSchema); @@ -11,7 +15,7 @@ describe("ClientSettingsSchema", () => { it("defaults worktreeBranchPrefix to 't3code' when missing from persisted settings", () => { const result = decode({}); - expect(result.worktreeBranchPrefix).toBe("t3code"); + expect(result.worktreeBranchPrefix).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); }); it("preserves a custom worktreeBranchPrefix value", () => { @@ -19,7 +23,21 @@ describe("ClientSettingsSchema", () => { expect(result.worktreeBranchPrefix).toBe("myteam"); }); + it("accepts dotted and digit-prefixed branch prefixes", () => { + expect(decode({ worktreeBranchPrefix: "my.team" }).worktreeBranchPrefix).toBe("my.team"); + expect(decode({ worktreeBranchPrefix: "3code" }).worktreeBranchPrefix).toBe("3code"); + }); + + it("falls back to the default worktreeBranchPrefix for empty or invalid values", () => { + expect(decode({ worktreeBranchPrefix: "" }).worktreeBranchPrefix).toBe( + DEFAULT_WORKTREE_BRANCH_PREFIX, + ); + expect(decode({ worktreeBranchPrefix: "team/name" }).worktreeBranchPrefix).toBe( + DEFAULT_WORKTREE_BRANCH_PREFIX, + ); + }); + it("has 't3code' as the default in DEFAULT_CLIENT_SETTINGS", () => { - expect(DEFAULT_CLIENT_SETTINGS.worktreeBranchPrefix).toBe("t3code"); + expect(DEFAULT_CLIENT_SETTINGS.worktreeBranchPrefix).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ceba6d3728..b03ea8eb54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option, SchemaIssue } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; @@ -22,6 +22,35 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +export const WORKTREE_BRANCH_PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export const WorktreeBranchPrefix = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => { + const normalized = value.trim(); + return Effect.succeed( + WORKTREE_BRANCH_PREFIX_PATTERN.test(normalized) + ? normalized + : DEFAULT_WORKTREE_BRANCH_PREFIX, + ); + }, + encode: (value) => { + const normalized = value.trim(); + return WORKTREE_BRANCH_PREFIX_PATTERN.test(normalized) + ? Effect.succeed(normalized) + : Effect.fail( + new SchemaIssue.InvalidValue(Option.some(value), { + title: "Invalid worktree branch prefix", + message: "Use 1-64 letters, numbers, dots, hyphens, or underscores.", + }), + ); + }, + }), + ), +); export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), @@ -34,8 +63,8 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), - worktreeBranchPrefix: Schema.String.check(Schema.isMaxLength(64)).pipe( - Schema.withDecodingDefault(() => "t3code"), + worktreeBranchPrefix: WorktreeBranchPrefix.pipe( + Schema.withDecodingDefault(() => DEFAULT_WORKTREE_BRANCH_PREFIX), ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; From 4c3b0fbd503692922334f812f5c4c69f4fa53cd4 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 21:10:30 +0100 Subject: [PATCH 4/6] fix: narrow temporary worktree branch matching --- .../Layers/ProviderCommandReactor.test.ts | 49 +++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 3 +- apps/web/src/components/ChatView.logic.ts | 4 +- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6b334b92c0..577c325f04 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -322,7 +322,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.makeUnsafe("cmd-thread-meta-update-branch"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "T3code/abcd1234", + branch: "T3code/worktree-abcd1234", worktreePath: "/tmp/provider-project", }), ); @@ -347,7 +347,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.renameBranch.mock.calls.length === 1); expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ cwd: "/tmp/provider-project", - oldBranch: "T3code/abcd1234", + oldBranch: "T3code/worktree-abcd1234", newBranch: "T3code/feature", }); @@ -374,7 +374,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.makeUnsafe("cmd-thread-meta-update-digit-branch"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "3code/abcd1234", + branch: "3code/worktree-abcd1234", worktreePath: "/tmp/provider-project", }), ); @@ -399,11 +399,52 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.renameBranch.mock.calls.length === 1); expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ cwd: "/tmp/provider-project", - oldBranch: "3code/abcd1234", + oldBranch: "3code/worktree-abcd1234", newBranch: "3code/feature", }); }); + it("does not rename ordinary user branches that happen to end with eight hex characters", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateBranchName.mockImplementationOnce(() => + Effect.succeed({ + branch: "feature/session-cleanup", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-update-user-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "feature/deadbeef", + worktreePath: "/tmp/provider-project", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-user-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-user-branch"), + role: "user", + text: "keep my branch name", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateBranchName).not.toHaveBeenCalled(); + expect(harness.renameBranch).not.toHaveBeenCalled(); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 289f3745fa..2714e5c8fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -73,8 +73,9 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +const TEMP_WORKTREE_BRANCH_MARKER = "worktree"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( - `^${WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}\\/[0-9a-f]{8}$`, + `^${WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}\\/${TEMP_WORKTREE_BRANCH_MARKER}-[0-9a-f]{8}$`, "i", ); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index db42e26178..8061a7e186 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -100,10 +100,10 @@ export function readFileAsDataUrl(file: File): Promise { } export function buildTemporaryWorktreeBranchName(prefix: string): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. + // Use an explicit temporary marker so backend rename logic does not match user branches. const resolvedPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix)(prefix); const token = randomUUID().slice(0, 8).toLowerCase(); - return `${resolvedPrefix}/${token}`; + return `${resolvedPrefix}/worktree-${token}`; } export function cloneComposerImageForRetry( From 434428086ccaa1bd0e61b7df76324e61bf4b343e Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 21:11:19 +0100 Subject: [PATCH 5/6] test: reuse provider reactor runtime in harness --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 577c325f04..f1ba484535 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -219,7 +219,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(layer); + runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); From a9fed1c146409b71ece8aefb079d10b1f17e45aa Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Sat, 28 Mar 2026 21:20:25 +0100 Subject: [PATCH 6/6] refactor: reuse shared worktree prefix default --- .../src/orchestration/Layers/ProviderCommandReactor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2714e5c8fc..529e7ea24f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,7 +11,10 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { WORKTREE_BRANCH_PREFIX_PATTERN } from "@t3tools/contracts/settings"; +import { + DEFAULT_WORKTREE_BRANCH_PREFIX, + WORKTREE_BRANCH_PREFIX_PATTERN, +} from "@t3tools/contracts/settings"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -72,7 +75,6 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_MARKER = "worktree"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( `^${WORKTREE_BRANCH_PREFIX_PATTERN.source.slice(1, -1)}\\/${TEMP_WORKTREE_BRANCH_MARKER}-[0-9a-f]{8}$`,