diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..1df7974ad44b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -735,6 +735,9 @@ export function NewTaskDraftScreen(props: { branch: creationBranch, worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, startFromOrigin, + ...(selectedEnvironmentServerConfig + ? { worktreeBranchPrefix: selectedEnvironmentServerConfig.settings.worktreeBranchPrefix } + : {}), runtimeMode, interactionMode, initialMessageText, diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..7b6e91b767f9 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -8,6 +8,7 @@ import { type ModelSelection, type ProviderInteractionMode, type RuntimeMode, + type WorktreeBranchPrefix, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; @@ -33,6 +34,7 @@ export function useCreateProjectThread() { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly worktreeBranchPrefix?: WorktreeBranchPrefix; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; @@ -74,7 +76,10 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName( + randomHex, + input.worktreeBranchPrefix, + ), }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..1bc21f613743 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -21,6 +21,7 @@ import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useThreadShells } from "./entities"; +import { serverEnvironment } from "./server"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -256,6 +257,11 @@ export function useThreadOutboxDrain(): void { return false; } const { completeDelivery } = makeDeliveryHelpers(queuedMessage); + // Dispatch-time snapshot: the queued creation should use whatever prefix + // the environment reports when the message finally sends. + const environmentServerConfig = appAtomRegistry.get( + serverEnvironment.configValueAtom(queuedMessage.environmentId), + ); const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -274,7 +280,10 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName( + randomHex, + environmentServerConfig?.settings.worktreeBranchPrefix, + ), }), }); return completeDelivery(deliveryResult); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..b2710720cc2e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -35,6 +35,7 @@ import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -349,6 +350,7 @@ describe("CheckpointReactor", () => { ), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..a2782224924f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -8,6 +8,7 @@ import { TurnId, type OrchestrationEvent, type ProviderRuntimeEvent, + type ServerSettingsError, type VcsStatusLocalResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -30,6 +31,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { forkParked } from "../../serverActivation.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; @@ -88,6 +90,7 @@ const make = Effect.gen(function* () { const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const serverSettingsService = yield* ServerSettingsService; const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -568,14 +571,19 @@ const make = Effect.gen(function* () { readonly cwd: string; readonly local: VcsStatusLocalResult; }) { - // Detached HEAD has no branch to adopt; a temporary placeholder checkout - // means the first-turn auto-rename is still in flight — don't race it. + // Detached HEAD has no branch to adopt. const checkedOutBranch = input.local.refName; - if (checkedOutBranch === null || isTemporaryWorktreeBranch(checkedOutBranch)) { + if (checkedOutBranch === null) { return; } yield* Effect.gen(function* () { + // A temporary placeholder checkout means the first-turn auto-rename is + // still in flight — don't race it. + const { worktreeBranchPrefix } = yield* serverSettingsService.getSettings; + if (isTemporaryWorktreeBranch(checkedOutBranch, worktreeBranchPrefix)) { + return; + } const thread = yield* projectionSnapshotQuery .getThreadShellById(input.threadId) .pipe(Effect.map(Option.getOrUndefined)); @@ -585,7 +593,7 @@ const make = Effect.gen(function* () { thread.branch === checkedOutBranch || thread.worktreePath === null || thread.worktreePath !== input.cwd || - isTemporaryWorktreeBranch(thread.branch) + isTemporaryWorktreeBranch(thread.branch, worktreeBranchPrefix) ) { return; } @@ -891,7 +899,10 @@ const make = Effect.gen(function* () { input: ReactorInput, ): Effect.Effect< void, - CheckpointStoreError | OrchestrationDispatchError | PlatformError.PlatformError, + | CheckpointStoreError + | OrchestrationDispatchError + | PlatformError.PlatformError + | ServerSettingsError, never > => input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..36b799d11651 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -60,7 +60,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; @@ -153,6 +153,7 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly worktreeBranchPrefix?: string; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -412,7 +413,13 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerSettings.layerTest( + input?.worktreeBranchPrefix + ? { worktreeBranchPrefix: input.worktreeBranchPrefix } + : undefined, + ), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -1456,59 +1463,58 @@ 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([ + ["configured", "codex/team/1234abcd", "codex/team/feature/reconnect-backoff"], + ["legacy", "t3code/1234abcd", "codex/team/feature/reconnect-backoff"], + ] as const)( + "renames the %s temporary branch into the configured prefix on the first turn", + async (_kind, temporaryBranch, expectedBranch) => { + const harness = await createHarness({ worktreeBranchPrefix: "codex/team" }); + 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: temporaryBranch, + 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.mockImplementation(() => + Effect.succeed({ branch: "feature/reconnect-backoff" }), + ); - 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.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + oldBranch: temporaryBranch, + newBranch: expectedBranch, + }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); + }, + ); 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..75fdabfdf421 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; @@ -793,10 +770,6 @@ const make = Effect.gen(function* () { if (!input.branch || !input.worktreePath) { return; } - if (!isTemporaryWorktreeBranch(input.branch)) { - return; - } - const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; @@ -809,6 +782,12 @@ const make = Effect.gen(function* () { settings, yield* providerRegistry.getProviders, ); + // Rename into the configured prefix even when the temporary branch was + // created under the legacy default (e.g. a client that had not loaded + // the setting yet), so a stale namespace heals on the first turn. + if (!isTemporaryWorktreeBranch(oldBranch, settings.worktreeBranchPrefix)) { + return; + } const generated = yield* textGeneration.generateBranchName({ cwd, @@ -818,7 +797,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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 20966deb0c4f..a26bd328716b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5433,7 +5433,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildTemporaryWorktreeBranchName( + randomHex, + settings.worktreeBranchPrefix, + ), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..715b71525903 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import { DEFAULT_WORKTREE_BRANCH_PREFIX, type VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildGitActionProgressStages, @@ -1059,6 +1059,7 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "feature/old-ref", gitStatus: status({ refName: "effect-atom" }), + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, }); assert.deepEqual(update, { @@ -1070,6 +1071,7 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "feature/old-ref", gitStatus: null, + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, }); assert.equal(update, null); @@ -1079,6 +1081,7 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", gitStatus: status({ refName: "effect-atom" }), + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, }); assert.equal(update, null); @@ -1088,6 +1091,7 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", gitStatus: status({ refName: null }), + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, }); assert.equal(update, null); @@ -1097,6 +1101,17 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "t3code/github-query-rate-limit", gitStatus: status({ refName: "t3code/bda76797" }), + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, + }); + + assert.equal(update, null); + }); + + it("does not regress a semantic legacy ref after the prefix changes", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "t3code/github-query-rate-limit", + gitStatus: status({ refName: "t3code/bda76797" }), + worktreeBranchPrefix: "codex/team", }); assert.equal(update, null); @@ -1106,10 +1121,21 @@ describe("resolveLiveThreadBranchUpdate", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "t3code/a9628676", gitStatus: status({ refName: "feature/diff-panel-toggle" }), + worktreeBranchPrefix: DEFAULT_WORKTREE_BRANCH_PREFIX, }); assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); }); + + it("reconciles an eight-hex branch outside the configured worktree prefix", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/current", + gitStatus: status({ refName: "release/20260714" }), + worktreeBranchPrefix: "codex/team", + }); + + assert.deepEqual(update, { branch: "release/20260714" }); + }); }); describe("resolveThreadBranchMetadataPatch", () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..3166587d0dc2 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -2,6 +2,7 @@ import type { GitRunStackedActionResult, GitStackedAction, VcsStatusResult, + WorktreeBranchPrefix, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { @@ -386,6 +387,7 @@ export function resolveThreadBranchMetadataPatch( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; + worktreeBranchPrefix: WorktreeBranchPrefix; }): { branch: string | null } | null { if (!input.gitStatus) { return null; @@ -402,8 +404,8 @@ export function resolveLiveThreadBranchUpdate(input: { if ( input.threadBranch !== null && input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) + !isTemporaryWorktreeBranch(input.threadBranch, input.worktreeBranchPrefix) && + isTemporaryWorktreeBranch(input.gitStatus.refName, input.worktreeBranchPrefix) ) { return null; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d448a720ebfc..a50eb17e1a56 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1127,14 +1127,23 @@ export default function GitActionsControl({ activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; + const worktreeBranchPrefix = serverConfig?.settings.worktreeBranchPrefix; + useEffect(() => { if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } + // Without the server config the configured prefix is unknown, and + // classifying against the default could persist a temporary branch as + // semantic. Reconciliation resumes once the config loads. + if (worktreeBranchPrefix === undefined) { + return; + } const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, + worktreeBranchPrefix, }); if (!branchUpdate) { return; @@ -1148,6 +1157,7 @@ export default function GitActionsControl({ isGitActionRunning, isSelectingWorktreeBase, persistThreadBranchSync, + worktreeBranchPrefix, ]); const isDefaultRef = useMemo(() => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 582dc9f6cb94..52ab8ba86136 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -36,6 +36,7 @@ import { MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { normalizeWorktreeBranchPrefix } from "@t3tools/shared/git"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -96,6 +97,7 @@ import { } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; +import { InputGroup, InputGroupAddon, InputGroupText } from "../ui/input-group"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, @@ -523,6 +525,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 +561,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeBranchPrefix, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -662,6 +668,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, @@ -2191,7 +2198,8 @@ export function GeneralSettingsPanel() { resetAction={ settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || settings.newWorktreesStartFromOrigin !== - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ( + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin || + settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix ? ( @@ -2199,6 +2207,7 @@ export function GeneralSettingsPanel() { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, }) } /> @@ -2231,34 +2240,74 @@ export function GeneralSettingsPanel() { /> {settings.defaultThreadEnvMode === "worktree" ? ( - - updateSettings({ - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) + <> + + updateSettings({ + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } + aria-label="Start new worktrees from origin by default" /> - ) : null - } - control={ - - updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) - } - aria-label="Start new worktrees from origin by default" - /> - } - /> + } + /> + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + + + updateSettings({ + worktreeBranchPrefix: normalizeWorktreeBranchPrefix(next), + }) + } + aria-label="Worktree branch prefix" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> + + / + + + } + /> + ) : null} { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults legacy configs to the T3 Code branch prefix", () => { + expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code"); + }); + + it("accepts branch prefix updates", () => { + expect( + decodeServerSettingsPatch({ worktreeBranchPrefix: "codex/feature" }).worktreeBranchPrefix, + ).toBe("codex/feature"); + }); + + it("rejects branch prefixes that are not canonical Git namespaces", () => { + expect(() => decodeServerSettingsPatch({ worktreeBranchPrefix: "Codex Branch" })).toThrow(); + expect(() => decodeServerSettingsPatch({ worktreeBranchPrefix: "team//feature" })).toThrow(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ba4facaf53ce..4101ba62bc57 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -265,6 +265,13 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // import cycle; re-exported here for compatibility with deep imports. export { ThreadEnvMode } from "./environment.ts"; +export const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +export const WorktreeBranchPrefix = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^(?!.*\/\/)[a-z0-9](?:[a-z0-9/_-]*[a-z0-9])?$/), +); +export type WorktreeBranchPrefix = typeof WorktreeBranchPrefix.Type; + const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -638,6 +645,9 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + worktreeBranchPrefix: WorktreeBranchPrefix.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_BRANCH_PREFIX)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -837,6 +847,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( diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..7d11dd725cab 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -3,11 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; import { applyGitStatusStreamEvent, + buildGeneratedWorktreeBranchName, buildTemporaryWorktreeBranchName, + DEFAULT_WORKTREE_BRANCH_PREFIX, + extractTemporaryWorktreeBranchPrefix, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, + normalizeWorktreeBranchPrefix, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - WORKTREE_BRANCH_PREFIX, } from "./git.ts"; describe("normalizeGitRemoteUrl", () => { @@ -75,38 +78,76 @@ describe("isTemporaryWorktreeBranch", () => { }); it("matches generated temporary worktree refs", () => { - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); - expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); + expect(isTemporaryWorktreeBranch(`${DEFAULT_WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); + expect(isTemporaryWorktreeBranch(` ${DEFAULT_WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); + expect(isTemporaryWorktreeBranch("codex/feature/DEADBEEF", "codex/feature")).toBe(true); }); it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => { expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( - `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, + `${DEFAULT_WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, ); }); it("matches legacy UUID-shaped temporary worktree refs from older mobile builds", () => { expect( - isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`), + isTemporaryWorktreeBranch( + `${DEFAULT_WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`, + ), ).toBe(true); }); it("rejects UUID-shaped refs that are not RFC 4122 v4", () => { // version nibble is not 4 expect( - isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12`), + isTemporaryWorktreeBranch( + `${DEFAULT_WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12`, + ), ).toBe(false); // variant nibble is not [89ab] expect( - isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12`), + isTemporaryWorktreeBranch( + `${DEFAULT_WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12`, + ), ).toBe(false); }); it("rejects non-temporary refName names", () => { - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); + expect(isTemporaryWorktreeBranch(`${DEFAULT_WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); + expect(isTemporaryWorktreeBranch(`${DEFAULT_WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe( + false, + ); + expect(isTemporaryWorktreeBranch("release/20260714")).toBe(false); + expect(isTemporaryWorktreeBranch("codex/feature/deadbeef", "codex/team")).toBe(false); + expect(isTemporaryWorktreeBranch("codex/team/release/deadbeef", "codex/team")).toBe(false); + }); +}); + +describe("worktree branch prefixes", () => { + it("normalizes user input into a stable Git namespace", () => { + expect(normalizeWorktreeBranchPrefix(" refs/heads/Codex Team//Feature/ ")).toBe( + "codex-team/feature", + ); + expect(normalizeWorktreeBranchPrefix(" ")).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); + }); + + it("uses the configured prefix for temporary and generated branches", () => { + expect(buildTemporaryWorktreeBranchName(() => "DEADBEEF", "codex/feature")).toBe( + "codex/feature/deadbeef", + ); + expect(extractTemporaryWorktreeBranchPrefix("codex/feature/deadbeef", "codex/feature")).toBe( + "codex/feature", + ); + expect(extractTemporaryWorktreeBranchPrefix("t3code/deadbeef", "codex/feature")).toBe( + DEFAULT_WORKTREE_BRANCH_PREFIX, + ); + expect(buildGeneratedWorktreeBranchName("codex/feature/add-search", "codex/feature")).toBe( + "codex/feature/add-search", + ); + expect(buildGeneratedWorktreeBranchName("Fix Search", "codex/feature")).toBe( + "codex/feature/fix-search", + ); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..f57b087f7b6a 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -5,19 +5,21 @@ import type { VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, + WorktreeBranchPrefix, } from "@t3tools/contracts"; +import { DEFAULT_WORKTREE_BRANCH_PREFIX } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; -export const WORKTREE_BRANCH_PREFIX = "t3code"; -// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` +export { DEFAULT_WORKTREE_BRANCH_PREFIX }; + +// Canonical form is `/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads // eligible for branch regeneration without loosening beyond what was ever generated. -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( - `^${WORKTREE_BRANCH_PREFIX}\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, -); +const TEMP_WORKTREE_BRANCH_TOKEN_PATTERN = + /^(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/; /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -41,6 +43,79 @@ export function sanitizeBranchFragment(raw: string): string { return branchFragment.length > 0 ? branchFragment : "update"; } +/** + * Canonicalize the namespace used for branches created by T3 Code. Keeping + * this at the Git boundary makes settings, temporary branches, and generated + * names obey the same ref rules. + */ +export function normalizeWorktreeBranchPrefix( + raw: string | null | undefined, +): WorktreeBranchPrefix { + const normalized = raw + ? raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, "") + : ""; + const prefix = normalized + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + return prefix.length > 0 ? prefix : DEFAULT_WORKTREE_BRANCH_PREFIX; +} + +/** + * Limit temporary branch detection to the configured namespace or the legacy + * default so ordinary branches with an eight-hex suffix remain untouched. + */ +export function extractTemporaryWorktreeBranchPrefix( + refName: string, + expectedPrefix: WorktreeBranchPrefix = DEFAULT_WORKTREE_BRANCH_PREFIX, +): WorktreeBranchPrefix | null { + const normalized = refName + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, ""); + const matchPrefix = (prefix: WorktreeBranchPrefix): WorktreeBranchPrefix | null => { + const prefixWithSeparator = `${prefix}/`; + if (!normalized.startsWith(prefixWithSeparator)) { + return null; + } + + const token = normalized.slice(prefixWithSeparator.length); + return TEMP_WORKTREE_BRANCH_TOKEN_PATTERN.test(token) ? prefix : null; + }; + + return ( + matchPrefix(expectedPrefix) ?? + (expectedPrefix === DEFAULT_WORKTREE_BRANCH_PREFIX + ? null + : matchPrefix(DEFAULT_WORKTREE_BRANCH_PREFIX)) + ); +} + +export function buildGeneratedWorktreeBranchName( + raw: string, + prefix: string | null | undefined, +): string { + const normalizedPrefix = normalizeWorktreeBranchPrefix(prefix); + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + const withoutPrefix = normalized.startsWith(`${normalizedPrefix}/`) + ? normalized.slice(`${normalizedPrefix}/`.length) + : normalized; + + return `${normalizedPrefix}/${sanitizeBranchFragment(withoutPrefix)}`; +} + /** * Sanitize a string into a `feature/…` refName name. * Preserves an existing `feature/` prefix or slash-separated namespace. @@ -94,6 +169,7 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, + prefix?: string | null, ): string { // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback // still produces the canonical temporary branch form. @@ -101,11 +177,14 @@ export function buildTemporaryWorktreeBranchName( .toLowerCase() .replace(/[^0-9a-f]/g, "") .slice(0, 8); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${normalizeWorktreeBranchPrefix(prefix)}/${token}`; } -export function isTemporaryWorktreeBranch(refName: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); +export function isTemporaryWorktreeBranch( + refName: string, + expectedPrefix: WorktreeBranchPrefix = DEFAULT_WORKTREE_BRANCH_PREFIX, +): boolean { + return extractTemporaryWorktreeBranchPrefix(refName, expectedPrefix) !== null; } /**