diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ed7037695f..09f6968ca7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -230,7 +230,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)); @@ -461,6 +461,143 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Reconnect spinner resume bug"); }); + 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/worktree-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/worktree-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("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/worktree-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/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("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -470,7 +607,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.makeUnsafe("cmd-thread-branch"), threadId: ThreadId.makeUnsafe("thread-1"), - branch: "t3code/1234abcd", + branch: "t3code/worktree-1234abcd", worktreePath: "/tmp/provider-project-worktree", }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d4f13ec727..6792fb6fd9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,6 +11,10 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; +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"; @@ -71,8 +75,11 @@ 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 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}$`, + "i", +); const DEFAULT_THREAD_TITLE = "New thread"; function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { @@ -122,15 +129,21 @@ 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 normalizedPrefix = prefix.trim().toLowerCase().replace(/['"`]/g, ""); 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(`${normalizedPrefix}/`) + ? normalized.slice(`${normalizedPrefix}/`.length) : normalized; const branchFragment = withoutPrefix @@ -142,7 +155,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* () { @@ -424,6 +437,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* () { @@ -438,7 +452,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..8061a7e186 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"; @@ -10,7 +11,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 +99,11 @@ export function readFileAsDataUrl(file: File): Promise { }); } -export function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. +export function buildTemporaryWorktreeBranchName(prefix: string): string { + // 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 `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${resolvedPrefix}/worktree-${token}`; } export function cloneComposerImageForRetry( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..3804d5593d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2604,7 +2604,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 f9fdb1d615..c411ca96b0 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 { canCheckForUpdate, @@ -539,6 +544,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(); @@ -563,6 +572,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; @@ -905,6 +941,46 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + 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} + /> + } + /> + { + 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(DEFAULT_WORKTREE_BRANCH_PREFIX); + }); + + it("preserves a custom worktreeBranchPrefix value", () => { + const result = decode({ worktreeBranchPrefix: "myteam" }); + 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(DEFAULT_WORKTREE_BRANCH_PREFIX); + }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..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,6 +63,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), + worktreeBranchPrefix: WorktreeBranchPrefix.pipe( + Schema.withDecodingDefault(() => DEFAULT_WORKTREE_BRANCH_PREFIX), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type;