diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6ae94105a..6a7f0283f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,4 +1,5 @@ import { + type GitWorktreeBranchNaming, ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, @@ -60,6 +61,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", } as const; +const DEFAULT_WORKTREE_BRANCH_NAMING: GitWorktreeBranchNaming = { mode: "auto" }; type ProjectorName = (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; @@ -425,6 +427,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + worktreeBranchNaming: + event.payload.worktreeBranchNaming ?? DEFAULT_WORKTREE_BRANCH_NAMING, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5fd38a540..674f653db 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,5 +1,6 @@ import { ChatAttachment, + GitWorktreeBranchNaming, IsoDateTime, MessageId, NonNegativeInt, @@ -53,7 +54,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + worktreeBranchNaming: Schema.fromJsonString(GitWorktreeBranchNaming), + }), +); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -161,6 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_branch_naming_json AS "worktreeBranchNaming", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -533,6 +539,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + worktreeBranchNaming: row.worktreeBranchNaming, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f..a8d5c6117 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -12,6 +12,7 @@ import { ProjectId, ThreadId, TurnId, + type GitWorktreeBranchNaming, } from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -83,7 +84,14 @@ describe("ProviderCommandReactor", () => { createdStateDirs.clear(); }); - async function createHarness(input?: { readonly stateDir?: string }) { + async function createHarness(input?: { + readonly stateDir?: string; + readonly threadCreate?: { + readonly branch?: string | null; + readonly worktreePath?: string | null; + readonly worktreeBranchNaming?: GitWorktreeBranchNaming; + }; + }) { const now = new Date().toISOString(); const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdStateDirs.add(stateDir); @@ -171,7 +179,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn(() => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -242,8 +250,11 @@ describe("ProviderCommandReactor", () => { model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - branch: null, - worktreePath: null, + branch: input?.threadCreate?.branch ?? null, + worktreePath: input?.threadCreate?.worktreePath ?? null, + ...(input?.threadCreate?.worktreeBranchNaming + ? { worktreeBranchNaming: input.threadCreate.worktreeBranchNaming } + : {}), createdAt: now, }), ); @@ -389,6 +400,92 @@ describe("ProviderCommandReactor", () => { }); }); + it("renames temporary worktree branches using a custom prefix", async () => { + const harness = await createHarness({ + threadCreate: { + branch: "team-branches/deadbeef", + worktreePath: "/tmp/provider-project/.t3/worktrees/team-branches", + worktreeBranchNaming: { + mode: "prefix", + prefix: "Team Branches", + }, + }, + }); + const now = new Date().toISOString(); + + harness.generateBranchName.mockImplementation( + () => + Effect.succeed({ + branch: "feat/session", + }) as ReturnType, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-custom-prefix"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-custom-prefix"), + role: "user", + text: "rename this worktree 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({ + oldBranch: "team-branches/deadbeef", + newBranch: "team-branches/feat/session", + }); + }); + + it("skips worktree branch rename when the full branch name is explicit", async () => { + const harness = await createHarness({ + threadCreate: { + branch: "feature/my-custom-branch", + worktreePath: "/tmp/provider-project/.t3/worktrees/full-branch", + worktreeBranchNaming: { + mode: "full", + branchName: "feature/my-custom-branch", + }, + }, + }); + const now = new Date().toISOString(); + + harness.generateBranchName.mockImplementation( + () => + Effect.succeed({ + branch: "ignored", + }) as ReturnType, + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-full-name"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-full-name"), + role: "user", + text: "use the explicit 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("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..e17759511 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,4 @@ +import { buildFinalWorktreeBranchName, isTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { type ChatAttachment, CommandId, @@ -71,8 +72,6 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const WORKTREE_BRANCH_PREFIX = "t3code"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); @@ -90,33 +89,6 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; -} - const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; @@ -369,14 +341,14 @@ const make = Effect.gen(function* () { if (!input.branch || !input.worktreePath) { return; } - if (!isTemporaryWorktreeBranch(input.branch)) { - return; - } const thread = yield* resolveThread(input.threadId); if (!thread) { return; } + if (!isTemporaryWorktreeBranchName(input.branch, thread.worktreeBranchNaming)) { + return; + } const userMessages = thread.messages.filter((message) => message.role === "user"); if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { @@ -402,7 +374,10 @@ const make = Effect.gen(function* () { Effect.flatMap((generated) => { if (!generated) return Effect.void; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildFinalWorktreeBranchName( + generated.branch, + thread.worktreeBranchNaming, + ); if (targetBranch === oldBranch) return Effect.void; return Effect.flatMap( diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index eea41a2b3..462fed56a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -161,6 +161,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.worktreeBranchNaming !== undefined + ? { worktreeBranchNaming: command.worktreeBranchNaming } + : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a67..c77ba71af 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -257,6 +257,9 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + ...(payload.worktreeBranchNaming !== undefined + ? { worktreeBranchNaming: payload.worktreeBranchNaming } + : {}), latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 10192697d..166aac844 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,8 +1,9 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; +import { GitWorktreeBranchNaming } from "@t3tools/contracts"; -import { toPersistenceSqlError } from "../Errors.ts"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadInput, GetProjectionThreadInput, @@ -12,11 +13,24 @@ import { type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; +const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( + Struct.assign({ + worktreeBranchNaming: Schema.fromJsonString(GitWorktreeBranchNaming), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const upsertProjectionThreadRow = SqlSchema.void({ - Request: ProjectionThread, + Request: ProjectionThreadDbRowSchema, execute: (row) => sql` INSERT INTO projection_threads ( @@ -28,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + worktree_branch_naming_json, latest_turn_id, created_at, updated_at, @@ -42,6 +57,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.worktreeBranchNaming}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -56,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + worktree_branch_naming_json = excluded.worktree_branch_naming_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -65,7 +82,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getProjectionThreadRow = SqlSchema.findOneOption({ Request: GetProjectionThreadInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRowSchema, execute: ({ threadId }) => sql` SELECT @@ -77,6 +94,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_branch_naming_json AS "worktreeBranchNaming", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -88,7 +106,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const listProjectionThreadRows = SqlSchema.findAll({ Request: ListProjectionThreadsByProjectInput, - Result: ProjectionThread, + Result: ProjectionThreadDbRowSchema, execute: ({ projectId }) => sql` SELECT @@ -100,6 +118,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_branch_naming_json AS "worktreeBranchNaming", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -121,17 +140,40 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => upsertProjectionThreadRow(row).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadRepository.upsert:query", + "ProjectionThreadRepository.upsert:encodeRequest", + ), + ), ); const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadRepository.getById:query", + "ProjectionThreadRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( - Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadRepository.listByProjectId:query", + "ProjectionThreadRepository.listByProjectId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd..3a3284b38 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_ProjectionThreadsWorktreeBranchNaming.ts"; import { Effect } from "effect"; /** @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_ProjectionThreadsWorktreeBranchNaming": Migration0014, }); /** diff --git a/apps/server/src/persistence/Migrations/014_ProjectionThreadsWorktreeBranchNaming.ts b/apps/server/src/persistence/Migrations/014_ProjectionThreadsWorktreeBranchNaming.ts new file mode 100644 index 000000000..a3a9c79c8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_ProjectionThreadsWorktreeBranchNaming.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN worktree_branch_naming_json TEXT NOT NULL DEFAULT '{"mode":"auto"}' + `; + + yield* sql` + UPDATE projection_threads + SET worktree_branch_naming_json = '{"mode":"auto"}' + WHERE worktree_branch_naming_json IS NULL OR trim(worktree_branch_naming_json) = '' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 7a30870f2..b468026c3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadRepository */ import { + GitWorktreeBranchNaming, IsoDateTime, ProjectId, ProviderInteractionMode, @@ -28,6 +29,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + worktreeBranchNaming: GitWorktreeBranchNaming, latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f..ff8c545e1 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,18 +6,29 @@ import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { + DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE, + updateDraftWorktreeBranchNamingMode, + type DraftWorktreeBranchNamingMode, +} from "../worktreeBranchNaming"; import { EnvMode, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; +import { Input } from "./ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; const envModeItems = [ { value: "local", label: "Local" }, { value: "worktree", label: "New worktree" }, ] as const; +const worktreeBranchNamingModeItems = [ + { value: "auto", label: "Auto Branch Name" }, + { value: "prefix", label: "Prefix" }, + { value: "full", label: "Custom Branch Name" }, +] as const; interface BranchToolbarProps { threadId: ThreadId; @@ -53,6 +64,10 @@ export default function BranchToolbar({ hasServerThread, draftThreadEnvMode: draftThread?.envMode, }); + const worktreeBranchNaming = + draftThread?.worktreeBranchNaming ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE; + const shouldShowWorktreeBranchNaming = + !envLocked && !hasServerThread && effectiveEnvMode === "worktree" && !activeWorktreePath; const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { @@ -106,54 +121,119 @@ export default function BranchToolbar({ ], ); + const updateWorktreeBranchNaming = useCallback( + (nextValue: typeof worktreeBranchNaming) => { + setDraftThreadContext(threadId, { + worktreeBranchNaming: nextValue, + }); + }, + [setDraftThreadContext, threadId], + ); + if (!activeThreadId || !activeProject) return null; return ( -
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? ( - <> - - Worktree - - ) : ( - <> - - Local - - )} - - ) : ( - onEnvModeChange(value as EnvMode)} + items={envModeItems} + > + + {effectiveEnvMode === "worktree" ? ( - New worktree - - - - - )} + ) : ( + + )} + + + + + + + Local + + + + + + New worktree + + + + + )} + + {shouldShowWorktreeBranchNaming ? ( +
+ + + {worktreeBranchNaming.mode !== "auto" ? ( + + updateWorktreeBranchNaming({ + ...worktreeBranchNaming, + ...(worktreeBranchNaming.mode === "prefix" + ? { prefix: event.target.value } + : { branchName: event.target.value }), + }) + } + /> + ) : null} +
+ ) : null} +
{ }); } -export function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; +export function buildTemporaryWorktreeBranchName(naming?: GitWorktreeBranchNaming): string { + return buildInitialWorktreeBranchName(naming, randomUUID().slice(0, 8).toLowerCase()); } export function cloneComposerImageForRetry( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e..b688e8438 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -159,6 +159,10 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { + resolveDraftWorktreeBranchNamingValidationError, + resolveWorktreeBranchNamingForThreadCreate, +} from "../worktreeBranchNaming"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -1900,6 +1904,7 @@ export default function ChatView({ threadId }: ChatViewProps) { : isLocalDraftThread ? (draftThread?.envMode ?? "local") : "local"; + const draftWorktreeBranchNaming = draftThread?.worktreeBranchNaming; useEffect(() => { if (phase !== "running") return; @@ -2252,6 +2257,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ); return; } + if (shouldCreateWorktree) { + const worktreeBranchNamingError = + resolveDraftWorktreeBranchNamingValidationError(draftWorktreeBranchNaming); + if (worktreeBranchNamingError) { + setStoreThreadError(threadIdForSend, worktreeBranchNamingError); + return; + } + } sendInFlightRef.current = true; beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); @@ -2302,11 +2315,14 @@ export default function ChatView({ threadId }: ChatViewProps) { let turnStartSucceeded = false; let nextThreadBranch = activeThread.branch; let nextThreadWorktreePath = activeThread.worktreePath; + const worktreeBranchNaming = shouldCreateWorktree + ? resolveWorktreeBranchNamingForThreadCreate(draftWorktreeBranchNaming) + : undefined; await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); + const newBranch = buildTemporaryWorktreeBranchName(worktreeBranchNaming); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, @@ -2359,6 +2375,7 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, + ...(worktreeBranchNaming ? { worktreeBranchNaming } : {}), createdAt: activeThread.createdAt, }); createdServerThreadForLocalDraft = true; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..0433ea6f3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -188,6 +188,11 @@ describe("composerDraftStore project draft thread mapping", () => { branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", + worktreeBranchNaming: { + mode: "auto", + prefix: "", + branchName: "", + }, runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", @@ -197,6 +202,11 @@ describe("composerDraftStore project draft thread mapping", () => { branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", + worktreeBranchNaming: { + mode: "auto", + prefix: "", + branchName: "", + }, runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", @@ -335,6 +345,27 @@ describe("composerDraftStore project draft thread mapping", () => { envMode: "worktree", }); }); + + it("stores worktree branch naming preferences on the draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setDraftThreadContext(threadId, { + worktreeBranchNaming: { + mode: "prefix", + prefix: "team-branches", + branchName: "", + }, + }); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + projectId, + worktreeBranchNaming: { + mode: "prefix", + prefix: "team-branches", + branchName: "", + }, + }); + }); }); describe("composerDraftStore codex fast mode", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..cbbcf214b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -13,6 +13,11 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachmen import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { + DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE, + normalizeDraftWorktreeBranchNaming, + type DraftWorktreeBranchNamingState, +} from "./worktreeBranchNaming"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; export type DraftThreadEnvMode = "local" | "worktree"; @@ -91,6 +96,7 @@ interface PersistedDraftThreadState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + worktreeBranchNaming?: DraftWorktreeBranchNamingState; } interface PersistedComposerDraftStoreState { @@ -120,6 +126,7 @@ export interface DraftThreadState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + worktreeBranchNaming?: DraftWorktreeBranchNamingState; } interface ProjectDraftThread extends DraftThreadState { @@ -142,6 +149,7 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + worktreeBranchNaming?: DraftWorktreeBranchNamingState; }, ) => void; setDraftThreadContext: ( @@ -154,6 +162,7 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + worktreeBranchNaming?: DraftWorktreeBranchNamingState; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; @@ -300,6 +309,20 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } +function areDraftWorktreeBranchNamingStatesEqual( + left: DraftWorktreeBranchNamingState | undefined, + right: DraftWorktreeBranchNamingState | undefined, +): boolean { + return ( + (left?.mode ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.mode) === + (right?.mode ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.mode) && + (left?.prefix ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.prefix) === + (right?.prefix ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.prefix) && + (left?.branchName ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.branchName) === + (right?.branchName ?? DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE.branchName) + ); +} + function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; @@ -347,6 +370,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + worktreeBranchNaming: normalizeDraftWorktreeBranchNaming( + candidateDraftThread.worktreeBranchNaming, + ), }; } } @@ -375,6 +401,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer branch: null, worktreePath: null, envMode: "local", + worktreeBranchNaming: { ...DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE }, }; } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { draftThreadsByThreadId[threadId as ThreadId] = { @@ -614,6 +641,10 @@ export const useComposerDraftStore = create()( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), + worktreeBranchNaming: options?.worktreeBranchNaming ?? + existingThread?.worktreeBranchNaming ?? { + ...DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE, + }, }; const hasSameProjectMapping = previousThreadIdForProject === threadId; const hasSameDraftThread = @@ -624,7 +655,11 @@ export const useComposerDraftStore = create()( existingThread.interactionMode === nextDraftThread.interactionMode && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && - existingThread.envMode === nextDraftThread.envMode; + existingThread.envMode === nextDraftThread.envMode && + areDraftWorktreeBranchNamingStatesEqual( + existingThread.worktreeBranchNaming, + nextDraftThread.worktreeBranchNaming, + ); if (hasSameProjectMapping && hasSameDraftThread) { return state; } @@ -684,6 +719,8 @@ export const useComposerDraftStore = create()( worktreePath: nextWorktreePath, envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + worktreeBranchNaming: options.worktreeBranchNaming ?? + existing.worktreeBranchNaming ?? { ...DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE }, }; const isUnchanged = nextDraftThread.projectId === existing.projectId && @@ -692,7 +729,11 @@ export const useComposerDraftStore = create()( nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && - nextDraftThread.envMode === existing.envMode; + nextDraftThread.envMode === existing.envMode && + areDraftWorktreeBranchNamingStatesEqual( + nextDraftThread.worktreeBranchNaming, + existing.worktreeBranchNaming, + ); if (isUnchanged) { return state; } diff --git a/apps/web/src/worktreeBranchNaming.test.ts b/apps/web/src/worktreeBranchNaming.test.ts new file mode 100644 index 000000000..d7ecc7e58 --- /dev/null +++ b/apps/web/src/worktreeBranchNaming.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE, + normalizeDraftWorktreeBranchNaming, + resolveDraftWorktreeBranchNamingValidationError, + resolveWorktreeBranchNamingForThreadCreate, + updateDraftWorktreeBranchNamingMode, +} from "./worktreeBranchNaming"; + +describe("normalizeDraftWorktreeBranchNaming", () => { + it("falls back to auto mode when the value is missing", () => { + expect(normalizeDraftWorktreeBranchNaming(undefined)).toEqual( + DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE, + ); + }); +}); + +describe("resolveDraftWorktreeBranchNamingValidationError", () => { + it("requires a prefix when prefix mode is selected", () => { + expect( + resolveDraftWorktreeBranchNamingValidationError({ + mode: "prefix", + prefix: " ", + branchName: "", + }), + ).toBe("Enter a prefix before sending in New worktree mode."); + }); + + it("requires a branch name when full mode is selected", () => { + expect( + resolveDraftWorktreeBranchNamingValidationError({ + mode: "full", + prefix: "", + branchName: " ", + }), + ).toBe("Enter a full branch name before sending in New worktree mode."); + }); +}); + +describe("resolveWorktreeBranchNamingForThreadCreate", () => { + it("returns auto mode when no draft state exists", () => { + expect(resolveWorktreeBranchNamingForThreadCreate(undefined)).toEqual({ mode: "auto" }); + }); + + it("returns a trimmed prefix configuration", () => { + expect( + resolveWorktreeBranchNamingForThreadCreate({ + mode: "prefix", + prefix: " team-branches ", + branchName: "", + }), + ).toEqual({ + mode: "prefix", + prefix: "team-branches", + }); + }); + + it("returns a trimmed full branch name configuration", () => { + expect( + resolveWorktreeBranchNamingForThreadCreate({ + mode: "full", + prefix: "", + branchName: " feature/custom-branch ", + }), + ).toEqual({ + mode: "full", + branchName: "feature/custom-branch", + }); + }); +}); + +describe("updateDraftWorktreeBranchNamingMode", () => { + it("seeds prefix mode with the default prefix", () => { + expect(updateDraftWorktreeBranchNamingMode(undefined, "prefix")).toEqual({ + mode: "prefix", + prefix: "t3code", + branchName: "", + }); + }); +}); diff --git a/apps/web/src/worktreeBranchNaming.ts b/apps/web/src/worktreeBranchNaming.ts new file mode 100644 index 000000000..41361c92c --- /dev/null +++ b/apps/web/src/worktreeBranchNaming.ts @@ -0,0 +1,80 @@ +import type { GitWorktreeBranchNaming } from "@t3tools/contracts"; +import { DEFAULT_WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; + +export type DraftWorktreeBranchNamingMode = GitWorktreeBranchNaming["mode"]; + +export interface DraftWorktreeBranchNamingState { + mode: DraftWorktreeBranchNamingMode; + prefix: string; + branchName: string; +} + +export const DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE: DraftWorktreeBranchNamingState = + Object.freeze({ + mode: "auto", + prefix: "", + branchName: "", + }); + +export function normalizeDraftWorktreeBranchNaming(value: unknown): DraftWorktreeBranchNamingState { + if (!value || typeof value !== "object") { + return { ...DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE }; + } + + const candidate = value as Record; + const mode = candidate.mode; + + return { + mode: mode === "prefix" || mode === "full" ? mode : "auto", + prefix: typeof candidate.prefix === "string" ? candidate.prefix : "", + branchName: typeof candidate.branchName === "string" ? candidate.branchName : "", + }; +} + +export function resolveDraftWorktreeBranchNamingValidationError( + state: DraftWorktreeBranchNamingState | null | undefined, +): string | null { + if (!state) { + return null; + } + + if (state.mode === "prefix" && state.prefix.trim().length === 0) { + return "Enter a prefix before sending in New worktree mode."; + } + + if (state.mode === "full" && state.branchName.trim().length === 0) { + return "Enter a full branch name before sending in New worktree mode."; + } + + return null; +} + +export function resolveWorktreeBranchNamingForThreadCreate( + state: DraftWorktreeBranchNamingState | null | undefined, +): GitWorktreeBranchNaming { + if (!state || state.mode === "auto") { + return { mode: "auto" }; + } + + if (state.mode === "prefix") { + const prefix = state.prefix.trim(); + return prefix.length > 0 ? { mode: "prefix", prefix } : { mode: "auto" }; + } + + const branchName = state.branchName.trim(); + return branchName.length > 0 ? { mode: "full", branchName } : { mode: "auto" }; +} + +export function updateDraftWorktreeBranchNamingMode( + state: DraftWorktreeBranchNamingState | null | undefined, + mode: DraftWorktreeBranchNamingMode, +): DraftWorktreeBranchNamingState { + const nextState = state ? { ...state } : { ...DEFAULT_DRAFT_WORKTREE_BRANCH_NAMING_STATE }; + + if (mode === "prefix" && nextState.prefix.trim().length === 0) { + nextState.prefix = DEFAULT_WORKTREE_BRANCH_PREFIX; + } + + nextState.mode = mode; + return nextState; +} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index b3775504f..5c3859e36 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -5,6 +5,7 @@ import { GitCreateWorktreeInput, GitPreparePullRequestThreadInput, GitResolvePullRequestResult, + GitWorktreeBranchNaming, } from "./git"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); @@ -12,6 +13,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeWorktreeBranchNaming = Schema.decodeUnknownSync(GitWorktreeBranchNaming); describe("GitCreateWorktreeInput", () => { it("accepts omitted newBranch for existing-branch worktrees", () => { @@ -56,3 +58,29 @@ describe("GitResolvePullRequestResult", () => { expect(parsed.pullRequest.headBranch).toBe("feature/pr-threads"); }); }); + +describe("GitWorktreeBranchNaming", () => { + it("decodes custom prefix mode", () => { + const parsed = decodeWorktreeBranchNaming({ + mode: "prefix", + prefix: "team-name", + }); + + expect(parsed).toEqual({ + mode: "prefix", + prefix: "team-name", + }); + }); + + it("decodes full branch name mode", () => { + const parsed = decodeWorktreeBranchNaming({ + mode: "full", + branchName: "feature/custom-branch", + }); + + expect(parsed).toEqual({ + mode: "full", + branchName: "feature/custom-branch", + }); + }); +}); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 081b4d0d8..0ef5386a0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -20,6 +20,22 @@ const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitWorktreeBranchNaming = Schema.Union([ + Schema.Struct({ + mode: Schema.Literal("auto"), + }), + Schema.Struct({ + mode: Schema.Literal("prefix"), + prefix: TrimmedNonEmptyStringSchema, + }), + Schema.Struct({ + mode: Schema.Literal("full"), + branchName: TrimmedNonEmptyStringSchema, + }), +]); +export type GitWorktreeBranchNaming = typeof GitWorktreeBranchNaming.Type; +export type GitWorktreeBranchNamingMode = GitWorktreeBranchNaming["mode"]; + export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, isRemote: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c5eb21d..81fbb6c38 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ProviderModelOptions } from "./model"; +import { GitWorktreeBranchNaming } from "./git"; import { ApprovalRequestId, CheckpointRef, @@ -261,6 +262,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + worktreeBranchNaming: Schema.optional(GitWorktreeBranchNaming), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -320,6 +322,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + worktreeBranchNaming: Schema.optional(GitWorktreeBranchNaming), createdAt: IsoDateTime, }); @@ -620,6 +623,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + worktreeBranchNaming: Schema.optional(GitWorktreeBranchNaming), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts new file mode 100644 index 000000000..8c05d9b07 --- /dev/null +++ b/packages/shared/src/git.test.ts @@ -0,0 +1,85 @@ +import type { GitWorktreeBranchNaming } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_WORKTREE_BRANCH_PREFIX, + buildFinalWorktreeBranchName, + buildInitialWorktreeBranchName, + isTemporaryWorktreeBranchName, +} from "./git"; + +describe("buildInitialWorktreeBranchName", () => { + it("uses the default prefix in auto mode", () => { + expect(buildInitialWorktreeBranchName({ mode: "auto" }, "deadbeef")).toBe("t3code/deadbeef"); + }); + + it("uses the configured prefix in prefix mode", () => { + const naming: GitWorktreeBranchNaming = { + mode: "prefix", + prefix: "Team Branches", + }; + + expect(buildInitialWorktreeBranchName(naming, "deadbeef")).toBe("team-branches/deadbeef"); + }); + + it("uses the full branch name without suffixes in full mode", () => { + const naming: GitWorktreeBranchNaming = { + mode: "full", + branchName: "feature/my-custom-branch", + }; + + expect(buildInitialWorktreeBranchName(naming, "deadbeef")).toBe("feature/my-custom-branch"); + }); +}); + +describe("buildFinalWorktreeBranchName", () => { + it("keeps the default prefix in auto mode", () => { + expect(buildFinalWorktreeBranchName("feat/session")).toBe("t3code/feat/session"); + }); + + it("replaces the default prefix with a custom prefix", () => { + expect( + buildFinalWorktreeBranchName("t3code/feat/session", { + mode: "prefix", + prefix: "team-branches", + }), + ).toBe("team-branches/feat/session"); + }); + + it("returns the explicit branch name in full mode", () => { + expect( + buildFinalWorktreeBranchName("ignored", { + mode: "full", + branchName: "feature/my-custom-branch", + }), + ).toBe("feature/my-custom-branch"); + }); +}); + +describe("isTemporaryWorktreeBranchName", () => { + it("detects temporary auto branches", () => { + expect(isTemporaryWorktreeBranchName("t3code/deadbeef")).toBe(true); + }); + + it("detects temporary custom-prefix branches", () => { + expect( + isTemporaryWorktreeBranchName("team-branches/deadbeef", { + mode: "prefix", + prefix: "Team Branches", + }), + ).toBe(true); + }); + + it("never treats explicit full branch names as temporary", () => { + expect( + isTemporaryWorktreeBranchName("feature/my-custom-branch", { + mode: "full", + branchName: "feature/my-custom-branch", + }), + ).toBe(false); + }); + + it("exports the default prefix constant", () => { + expect(DEFAULT_WORKTREE_BRANCH_PREFIX).toBe("t3code"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index bbd290393..21853711f 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,3 +1,5 @@ +import type { GitWorktreeBranchNaming } from "@t3tools/contracts"; + /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. @@ -33,6 +35,73 @@ export function sanitizeFeatureBranchName(raw: string): string { } const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; +export const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; +const TEMP_WORKTREE_BRANCH_TOKEN_PATTERN = /^[0-9a-f]{8}$/; + +function resolveWorktreeBranchPrefix(naming?: GitWorktreeBranchNaming): string { + if (naming?.mode === "prefix") { + return sanitizeBranchFragment(naming.prefix); + } + return DEFAULT_WORKTREE_BRANCH_PREFIX; +} + +function stripKnownWorktreePrefix(raw: string, configuredPrefix: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + const candidatePrefixes = new Set([configuredPrefix, DEFAULT_WORKTREE_BRANCH_PREFIX]); + for (const prefix of candidatePrefixes) { + const prefixWithSeparator = `${prefix}/`; + if (normalized.startsWith(prefixWithSeparator)) { + return normalized.slice(prefixWithSeparator.length); + } + } + return normalized; +} + +export function buildInitialWorktreeBranchName( + naming?: GitWorktreeBranchNaming, + token = crypto.randomUUID().slice(0, 8).toLowerCase(), +): string { + if (naming?.mode === "full") { + return naming.branchName.trim(); + } + return `${resolveWorktreeBranchPrefix(naming)}/${token}`; +} + +export function buildFinalWorktreeBranchName( + rawGeneratedBranch: string, + naming?: GitWorktreeBranchNaming, +): string { + if (naming?.mode === "full") { + return naming.branchName.trim(); + } + + const prefix = resolveWorktreeBranchPrefix(naming); + const branchFragment = sanitizeBranchFragment( + stripKnownWorktreePrefix(rawGeneratedBranch, prefix), + ); + return `${prefix}/${branchFragment}`; +} + +export function isTemporaryWorktreeBranchName( + branch: string, + naming?: GitWorktreeBranchNaming, +): boolean { + if (naming?.mode === "full") { + return false; + } + + const normalized = branch.trim().toLowerCase(); + const prefix = resolveWorktreeBranchPrefix(naming); + if (!normalized.startsWith(`${prefix}/`)) { + return false; + } + const token = normalized.slice(prefix.length + 1); + return TEMP_WORKTREE_BRANCH_TOKEN_PATTERN.test(token); +} /** * Resolve a unique `feature/…` branch name that doesn't collide with