diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..724d1c7cbbe1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -735,6 +735,7 @@ export function NewTaskDraftScreen(props: { branch: creationBranch, worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, startFromOrigin, + 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..305e586b63ef 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -9,7 +9,6 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -17,7 +16,6 @@ import { threadEnvironment } from "../../state/threads"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; -import { randomHex } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; @@ -33,6 +31,7 @@ export function useCreateProjectThread() { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly worktreeBranchPrefix?: string; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; @@ -74,7 +73,7 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchPrefix: input.worktreeBranchPrefix, }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts new file mode 100644 index 000000000000..8629d7659b7a --- /dev/null +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ProjectId, ProviderInstanceId } from "@t3tools/contracts"; + +vi.mock("./uuid", () => ({ + randomHex: () => "deadbeef", +})); + +import { + buildProjectThreadStartTurnInput, + type ProjectThreadStartTurnSpec, +} from "./projectThreadStartTurn"; + +const makeSpec = ( + overrides: Partial = {}, +): ProjectThreadStartTurnSpec => ({ + projectId: ProjectId.make("project-1"), + projectCwd: "/workspace/project-1", + threadId: "thread-1", + commandId: "command-1", + messageId: "message-1", + createdAt: "2026-08-10T12:00:00.000Z", + text: "Build the mobile change", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "approval-required", + interactionMode: "default", + workspaceMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: false, + ...overrides, +}); + +describe("buildProjectThreadStartTurnInput", () => { + it("uses the configured prefix for a worktree bootstrap branch", () => { + const input = buildProjectThreadStartTurnInput( + makeSpec({ + worktreeBranchPrefix: "mobile-team", + }), + ); + + expect(input.bootstrap.prepareWorktree?.branch).toBe("mobile-team/deadbeef"); + }); + + it("falls back to the default prefix when the server configuration is unavailable", () => { + const input = buildProjectThreadStartTurnInput(makeSpec()); + + expect(input.bootstrap.prepareWorktree?.branch).toBe("t3code/deadbeef"); + }); + + it("omits worktree preparation for a local thread", () => { + const input = buildProjectThreadStartTurnInput( + makeSpec({ + workspaceMode: "local", + worktreePath: "/workspace/project-1", + }), + ); + + expect(input.bootstrap.prepareWorktree).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..f7359d141230 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -7,8 +7,10 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; +import { randomHex } from "./uuid"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -36,8 +38,8 @@ export interface ProjectThreadStartTurnSpec { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin: boolean; - /** Generated temp branch for worktree mode; unused for local mode. */ - readonly worktreeBranchName: string; + /** Target environment setting; defaults for unavailable older configurations. */ + readonly worktreeBranchPrefix?: string; } /** @@ -77,7 +79,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe prepareWorktree: { projectCwd: spec.projectCwd, baseBranch: spec.branch!, - branch: spec.worktreeBranchName, + branch: buildTemporaryWorktreeBranchName(randomHex, spec.worktreeBranchPrefix), ...(spec.startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..b1ace3d76636 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -10,7 +10,6 @@ import { DEFAULT_RUNTIME_MODE, type MessageId, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -18,9 +17,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { toUploadChatImageAttachments } from "../lib/composerImages"; -import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useThreadShells } from "./entities"; +import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -102,6 +100,7 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); @@ -250,6 +249,7 @@ export function useThreadOutboxDrain(): void { queuedMessage: QueuedThreadMessage, creation: QueuedThreadCreation, projectCwd: string, + worktreeBranchPrefix: string | undefined, ) => { const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { @@ -274,7 +274,7 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchPrefix, }), }); return completeDelivery(deliveryResult); @@ -305,6 +305,8 @@ export function useThreadOutboxDrain(): void { } const creation = nextQueuedMessage.creation; + const worktreeBranchPrefix = serverConfigs.get(nextQueuedMessage.environmentId)?.settings + .worktreeBranchPrefix; const environment = connectedEnvironments.find( (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, ); @@ -372,7 +374,12 @@ export function useThreadOutboxDrain(): void { ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined ? creationProjectCwd !== null - ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) + ? sendQueuedCreation( + nextQueuedMessage, + creation, + creationProjectCwd, + worktreeBranchPrefix, + ) : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project") : thread !== undefined ? sendQueuedMessage(nextQueuedMessage, thread) @@ -417,6 +424,7 @@ export function useThreadOutboxDrain(): void { projects, queuedMessagesByThreadKey, retryTick, + serverConfigs, sendQueuedCreation, sendQueuedMessage, shellStatuses, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ca4cb7afd9ab..af7016890675 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -57,6 +57,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; @@ -351,6 +352,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(ServerSettingsService.layerTest({ worktreeBranchPrefix: "team" })), Layer.provideMerge(NodeServices.layer), ); @@ -612,7 +614,7 @@ describe("CheckpointReactor", () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, threadBranch: "t3code/original-branch", - localStatusRefName: "t3code/0a1b2c3d", + localStatusRefName: "t3code/12345678-1234-4abc-8def-1234567890ab", }); harness.provider.emit({ @@ -632,6 +634,30 @@ describe("CheckpointReactor", () => { expect(thread?.branch).toBe("t3code/original-branch"); }); + it("does not adopt a configured temporary placeholder checkout as the thread branch", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "team/original-branch", + localStatusRefName: "team/0a1b2c3d", + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-branch-drift-custom-temp"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-branch-drift-custom-temp"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.branch).toBe("team/original-branch"); + }); + it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..cfe21f9dbf9f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -88,6 +89,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; @@ -571,7 +573,21 @@ const make = Effect.gen(function* () { // 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. const checkedOutBranch = input.local.refName; - if (checkedOutBranch === null || isTemporaryWorktreeBranch(checkedOutBranch)) { + if (checkedOutBranch === null) { + return; + } + const settings = yield* serverSettingsService.getSettings.pipe( + Effect.catch((error) => + Effect.logWarning("failed to read settings while following worktree branch drift", { + threadId: input.threadId, + detail: error.message, + }).pipe(Effect.as(null)), + ), + ); + if (settings === null) { + return; + } + if (isTemporaryWorktreeBranch(checkedOutBranch, settings.worktreeBranchPrefix)) { return; } @@ -585,7 +601,7 @@ const make = Effect.gen(function* () { thread.branch === checkedOutBranch || thread.worktreePath === null || thread.worktreePath !== input.cwd || - isTemporaryWorktreeBranch(thread.branch) + isTemporaryWorktreeBranch(thread.branch, settings.worktreeBranchPrefix) ) { return; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3588244d827..37af373a5269 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -152,6 +152,7 @@ describe("ProviderCommandReactor", () => { readonly titleRegenerationBeforeStart?: "one" | "two"; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; + readonly worktreeBranchPrefix?: "team"; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -426,7 +427,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), ); @@ -1486,19 +1493,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.mockReturnValue( + Effect.succeed({ branch: "t3code/feature/reconnect-backoff" }), ); await Effect.runPromise( @@ -1524,6 +1520,11 @@ describe("ProviderCommandReactor", () => { message: "Add a safer reconnect backoff.", }); expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); + expect(harness.renameBranch).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project-worktree", + oldBranch: "t3code/1234abcd", + newBranch: "t3code/feature/reconnect-backoff", + }); }); it("recreates a missing worktree from the thread branch before starting a turn", async () => { @@ -1570,6 +1571,53 @@ describe("ProviderCommandReactor", () => { ); }); + it.each([ + ["team/1234abcd", "team/feature/reconnect-backoff"], + ["team/1234abcd", "t3code/feature/reconnect-backoff"], + ["t3code/12345678-1234-4abc-8def-1234567890ab", "feature/reconnect-backoff"], + ])( + "renames a custom temporary branch without duplicating the generated prefix: %s to %s", + async (oldBranch, generatedBranch) => { + const harness = await createHarness({ worktreeBranchPrefix: "team" }); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-branch-custom-prefix"), + threadId: ThreadId.make("thread-1"), + branch: oldBranch, + worktreePath: "/tmp/provider-project-worktree", + }), + ); + harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: generatedBranch })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-branch-custom-prefix"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-branch-custom-prefix"), + role: "user", + text: "Add a safer reconnect backoff.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + expect(harness.renameBranch).toHaveBeenCalledWith({ + cwd: "/tmp/provider-project-worktree", + oldBranch, + newBranch: "team/feature/reconnect-backoff", + }); + }, + ); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 812893d8c843..c57ce8f90a75 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 { DEFAULT_WORKTREE_BRANCH_PREFIX, isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -278,16 +278,17 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, worktreeBranchPrefix: 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 matchedPrefix = [worktreeBranchPrefix, DEFAULT_WORKTREE_BRANCH_PREFIX].find((prefix) => + normalized.startsWith(`${prefix}/`), + ); + const withoutPrefix = matchedPrefix ? normalized.slice(`${matchedPrefix}/`.length) : normalized; const branchFragment = withoutPrefix .replace(/[^a-z0-9/_-]+/g, "-") @@ -298,7 +299,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${worktreeBranchPrefix}/${safeFragment}`; } const make = Effect.gen(function* () { @@ -843,15 +844,16 @@ 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 ?? []; yield* Effect.gen(function* () { const settings = yield* serverSettingsService.getSettings; + if (!isTemporaryWorktreeBranch(oldBranch, settings.worktreeBranchPrefix)) { + return; + } + const modelSelection = settings.sourceControlWriterModelSelection === null ? settings.textGenerationModelSelection @@ -868,7 +870,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 f0188af478c0..17644bf03e68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5840,7 +5840,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildTemporaryWorktreeBranchName( + randomHex, + serverConfig?.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..62c3e1f3aa29 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1102,6 +1102,16 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.equal(update, null); }); + it("does not regress a semantic thread ref back to a configured temporary worktree ref", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/github-query-rate-limit", + gitStatus: status({ refName: "team/bda76797" }), + worktreeBranchPrefix: "team", + }); + + assert.equal(update, null); + }); + it("allows a temporary worktree ref to reconcile to a semantic branch", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "t3code/a9628676", @@ -1110,6 +1120,16 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); }); + + it("allows a configured temporary worktree ref to reconcile to a semantic branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "team/a9628676", + gitStatus: status({ refName: "feature/diff-panel-toggle" }), + worktreeBranchPrefix: "team", + }); + + assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); + }); }); describe("resolveThreadBranchMetadataPatch", () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..6fe5387dd8e1 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -386,6 +386,7 @@ export function resolveThreadBranchMetadataPatch( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; + worktreeBranchPrefix?: string; }): { branch: string | null } | null { if (!input.gitStatus) { return null; @@ -402,8 +403,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..8a139ffe92cf 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1128,13 +1128,14 @@ export default function GitActionsControl({ activeDraftThread.worktreePath === null; useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { + if (!serverConfig || isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, + worktreeBranchPrefix: serverConfig.settings.worktreeBranchPrefix, }); if (!branchUpdate) { return; @@ -1148,6 +1149,7 @@ export default function GitActionsControl({ isGitActionRunning, isSelectingWorktreeBase, persistThreadBranchSync, + serverConfig?.settings.worktreeBranchPrefix, ]); const isDefaultRef = useMemo(() => { diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 5c715eb4eb25..c06c9f14f47e 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -19,6 +19,7 @@ import { isProjectGroupingEnabled, projectGroupingModeFromToggle, resolveBackgroundActivityProfileOption, + resolveWorktreeBranchPrefixUpdate, } from "./SettingsPanels.logic"; describe("typography settings restore", () => { @@ -295,3 +296,18 @@ describe("isSamePreviewViewport", () => { ).toBe(false); }); }); + +describe("resolveWorktreeBranchPrefixUpdate", () => { + it("trims and lowercases a valid prefix before saving", () => { + expect(resolveWorktreeBranchPrefixUpdate(" Team_42-Dev ")).toEqual({ + worktreeBranchPrefix: "team_42-dev", + }); + }); + + it.each(["", "-team", "team/feature", "team.prefix", "a".repeat(65)])( + "does not create a settings update for invalid prefix %j", + (value) => { + expect(resolveWorktreeBranchPrefixUpdate(value)).toBeNull(); + }, + ); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index a5d5d9958498..5383734455f5 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,13 +1,14 @@ -import type { - BackgroundActivityProfile, - BackgroundActivitySettings, - ProviderDriverKind, - ProviderInstanceConfig, - PreviewViewportSetting, - ProviderInstanceId, - ServerSettings, - SidebarProjectGroupingMode, - UnifiedSettings, +import { + WorktreeBranchPrefix, + type BackgroundActivityProfile, + type BackgroundActivitySettings, + type ProviderDriverKind, + type ProviderInstanceConfig, + type PreviewViewportSetting, + type ProviderInstanceId, + type ServerSettings, + type SidebarProjectGroupingMode, + type UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { @@ -18,6 +19,19 @@ import { } from "@t3tools/shared/backgroundActivitySettings"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; +import * as Schema from "effect/Schema"; + +const decodeWorktreeBranchPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix); + +export function resolveWorktreeBranchPrefixUpdate( + value: string, +): { worktreeBranchPrefix: string } | null { + try { + return { worktreeBranchPrefix: decodeWorktreeBranchPrefix(value) }; + } catch { + return null; + } +} export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..e8c967b250ce 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -96,6 +96,7 @@ import { } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "../ui/input-group"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, @@ -135,6 +136,7 @@ import { readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, + resolveWorktreeBranchPrefixUpdate, } from "./SettingsPanels.logic"; import { PolicyTooltip, @@ -526,6 +528,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"] : []), @@ -559,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeBranchPrefix, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -667,6 +673,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, @@ -1856,6 +1863,11 @@ function LegacyFeaturesSection() { export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const [worktreeBranchPrefixDraft, setWorktreeBranchPrefixDraft] = useState( + settings.worktreeBranchPrefix, + ); + const [worktreeBranchPrefixInvalid, setWorktreeBranchPrefixInvalid] = useState(false); + const isWorktreeBranchPrefixFocused = useRef(false); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), @@ -1905,6 +1917,25 @@ export function GeneralSettingsPanel() { settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity, ); + useEffect(() => { + if (isWorktreeBranchPrefixFocused.current) { + return; + } + setWorktreeBranchPrefixDraft(settings.worktreeBranchPrefix); + setWorktreeBranchPrefixInvalid(false); + }, [settings.worktreeBranchPrefix]); + const commitWorktreeBranchPrefix = useCallback(() => { + const patch = resolveWorktreeBranchPrefixUpdate(worktreeBranchPrefixDraft); + if (!patch) { + setWorktreeBranchPrefixInvalid(true); + return; + } + setWorktreeBranchPrefixDraft(patch.worktreeBranchPrefix); + setWorktreeBranchPrefixInvalid(false); + if (patch.worktreeBranchPrefix !== settings.worktreeBranchPrefix) { + updateSettings(patch); + } + }, [settings.worktreeBranchPrefix, updateSettings, worktreeBranchPrefixDraft]); return ( @@ -2261,6 +2292,67 @@ export function GeneralSettingsPanel() { } /> + + Start with a letter, digit, or underscore, then use letters, digits, hyphens, or + underscores. + + ) : undefined + } + resetAction={ + settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix ? ( + { + const worktreeBranchPrefix = DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix; + setWorktreeBranchPrefixDraft(worktreeBranchPrefix); + setWorktreeBranchPrefixInvalid(false); + updateSettings({ worktreeBranchPrefix }); + }} + /> + ) : null + } + control={ + + { + isWorktreeBranchPrefixFocused.current = true; + }} + onBlur={() => { + isWorktreeBranchPrefixFocused.current = false; + commitWorktreeBranchPrefix(); + }} + onChange={(event) => { + setWorktreeBranchPrefixDraft(event.currentTarget.value); + setWorktreeBranchPrefixInvalid(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> + + / + + + } + /> + {settings.defaultThreadEnvMode === "worktree" ? ( { expect(new Set(ids).size).toBe(ids.length); }); + it("finds the worktree branch prefix setting", () => { + expect(searchSettings("worktree branch prefix")[0]).toMatchObject({ + id: "worktree-branch-prefix", + to: "/settings/general", + }); + }); + it("serves anchor props to panels from the catalog", () => { expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" }); expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5213cb55a503..28a51b34a7b4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -146,6 +146,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "New threads", to: "/settings/general", }, + { + id: "worktree-branch-prefix", + title: "Worktree branch prefix", + to: "/settings/general", + }, { id: "start-from-origin", title: "Start from origin", diff --git a/docs/README.md b/docs/README.md index 622d81064387..35d546e4de45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [Install and first run](./user/install.md) - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) +- [Worktrees](./user/worktrees.md) - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) diff --git a/docs/user/worktrees.md b/docs/user/worktrees.md new file mode 100644 index 000000000000..dd6981ef540f --- /dev/null +++ b/docs/user/worktrees.md @@ -0,0 +1,31 @@ +# Worktrees + +T3 Code can create a Git worktree when you start a thread. Select **New worktree** and choose the base branch. + +The worktree gives the thread an isolated checkout. Your original checkout stays on its current branch. + +## Set the branch prefix + +1. Open **Settings**. +2. Select **General**. +3. Enter a value in **Worktree branch prefix**. + +The default prefix is `t3code`. A temporary branch can have the name `t3code/a1b2c3d4`. + +Use one segment that starts with a letter, digit, or underscore. The rest can use letters, digits, hyphens, or underscores. The maximum length is 64 characters. + +T3 Code removes spaces at the start and end. It saves uppercase letters as lowercase letters. + +The field shows the final `/`. Do not enter the slash in the prefix. + +Each server stores its own prefix. A remote or mobile task uses the setting from the selected environment. + +T3 Code can replace the temporary name after the first turn. The new branch keeps the configured prefix. + +The worktree directory follows the temporary branch name. T3 Code replaces `/` with `-` in the default directory segment. + +For example, `team/a1b2c3d4` uses `team-a1b2c3d4` in the default worktree path. + +Changing the setting does not rename existing branches or worktrees. It applies only to new thread worktrees. + +T3 Code recognizes the current prefix and legacy `t3code` branches as temporary. It does not keep a history of custom prefixes. If a temporary branch uses an older custom prefix, T3 Code no longer applies automatic branch naming to it. diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index 436db17110f1..62b24b5dd252 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -412,6 +412,7 @@ export function resolveThreadBranchUpdate( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; + worktreeBranchPrefix?: string; }): { branch: string | null } | null { if (!input.gitStatus) { return null; @@ -428,8 +429,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/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d75b0b4ae798..1f8c7f744936 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -286,6 +286,31 @@ describe("ServerSettings worktree defaults", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); }); + it("defaults and normalizes the worktree branch prefix", () => { + expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code"); + expect( + decodeServerSettings({ worktreeBranchPrefix: " Team_42-Dev " }).worktreeBranchPrefix, + ).toBe("team_42-dev"); + expect( + decodeServerSettingsPatch({ worktreeBranchPrefix: " Team_42-Dev " }).worktreeBranchPrefix, + ).toBe("team_42-dev"); + }); + + it.each(["a", "7", "_"])("accepts one-character worktree branch prefix %j", (value) => { + expect(decodeServerSettings({ worktreeBranchPrefix: value }).worktreeBranchPrefix).toBe(value); + expect(decodeServerSettingsPatch({ worktreeBranchPrefix: value }).worktreeBranchPrefix).toBe( + value, + ); + }); + + it.each(["", " ", "-team", "team/feature", "team.prefix", "a".repeat(65)])( + "rejects invalid worktree branch prefix %j", + (worktreeBranchPrefix) => { + expect(() => decodeServerSettings({ worktreeBranchPrefix })).toThrow(); + expect(() => decodeServerSettingsPatch({ worktreeBranchPrefix })).toThrow(); + }, + ); + it("accepts start-from-origin updates", () => { expect( decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866e..6fb0afd8d3c5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -124,6 +124,19 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +export const WorktreeBranchPrefix = TrimmedNonEmptyString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value.toLowerCase()), + encode: (value) => Effect.succeed(value.toLowerCase()), + }), + ), + Schema.check(Schema.isMaxLength(64), Schema.isPattern(/^[a-z0-9_][a-z0-9_-]*$/)), +); +export type WorktreeBranchPrefix = typeof WorktreeBranchPrefix.Type; + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -658,6 +671,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( @@ -863,6 +879,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..b08e52c38be2 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -80,6 +80,41 @@ describe("isTemporaryWorktreeBranch", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); }); + it("builds custom temporary refs while recognizing custom and legacy placeholders", () => { + expect(buildTemporaryWorktreeBranchName(() => "DEADBEEF", "team_42-dev")).toBe( + "team_42-dev/deadbeef", + ); + expect(isTemporaryWorktreeBranch("team_42-dev/deadbeef", "team_42-dev")).toBe(true); + expect( + isTemporaryWorktreeBranch( + `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`, + "team_42-dev", + ), + ).toBe(true); + }); + + it("normalizes custom prefixes before building and matching", () => { + expect(buildTemporaryWorktreeBranchName(() => "DEADBEEF", " Team_42-Dev ")).toBe( + "team_42-dev/deadbeef", + ); + expect(isTemporaryWorktreeBranch("team_42-dev/deadbeef", " Team_42-Dev ")).toBe(true); + }); + + it("does not retain previously configured custom prefixes", () => { + expect(isTemporaryWorktreeBranch("team/deadbeef", "org")).toBe(false); + expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`, "org")).toBe(true); + }); + + it.each(["", " ", "t3.code", "team/feature", "a".repeat(65)])( + "rejects invalid custom prefix %j", + (worktreeBranchPrefix) => { + expect(() => + buildTemporaryWorktreeBranchName(() => "deadbeef", worktreeBranchPrefix), + ).toThrow(); + expect(() => isTemporaryWorktreeBranch("t3code/deadbeef", worktreeBranchPrefix)).toThrow(); + }, + ); + 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`, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..4780493892f9 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,3 +1,4 @@ +import { DEFAULT_WORKTREE_BRANCH_PREFIX, WorktreeBranchPrefix } from "@t3tools/contracts"; import type { VcsRef, SourceControlProviderInfo, @@ -8,16 +9,23 @@ import type { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; -export const WORKTREE_BRANCH_PREFIX = "t3code"; +export { DEFAULT_WORKTREE_BRANCH_PREFIX } from "@t3tools/contracts"; +export const WORKTREE_BRANCH_PREFIX = DEFAULT_WORKTREE_BRANCH_PREFIX; // Canonical form is `t3code/<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 escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const buildTemporaryWorktreeBranchPattern = (prefixes: ReadonlyArray): RegExp => + new RegExp( + `^(?:${prefixes.map(escapeRegExp).join("|")})\\/(?:[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 decodeWorktreeBranchPrefix = Schema.decodeUnknownSync(WorktreeBranchPrefix); /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -94,6 +102,7 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, + worktreeBranchPrefix = DEFAULT_WORKTREE_BRANCH_PREFIX, ): string { // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback // still produces the canonical temporary branch form. @@ -101,11 +110,17 @@ export function buildTemporaryWorktreeBranchName( .toLowerCase() .replace(/[^0-9a-f]/g, "") .slice(0, 8); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${decodeWorktreeBranchPrefix(worktreeBranchPrefix)}/${token}`; } -export function isTemporaryWorktreeBranch(refName: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); +export function isTemporaryWorktreeBranch( + refName: string, + worktreeBranchPrefix = DEFAULT_WORKTREE_BRANCH_PREFIX, +): boolean { + const prefixes = [ + ...new Set([decodeWorktreeBranchPrefix(worktreeBranchPrefix), DEFAULT_WORKTREE_BRANCH_PREFIX]), + ]; + return buildTemporaryWorktreeBranchPattern(prefixes).test(refName.trim().toLowerCase()); } /**