From 9b27f32ccd106e7ac904b1b3a73b77fb6dd4364a Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 19:15:13 +1100 Subject: [PATCH] feat: make worktree branch prefix configurable --- apps/server/src/git/Layers/GitManager.test.ts | 55 +++++++++++++- apps/server/src/git/Layers/GitManager.ts | 24 +++--- .../Layers/ProviderCommandReactor.test.ts | 57 +++++++++++++- .../Layers/ProviderCommandReactor.ts | 39 +++------- apps/web/src/appSettings.ts | 5 ++ apps/web/src/components/ChatView.tsx | 9 ++- .../components/PullRequestThreadDialog.tsx | 4 + apps/web/src/lib/gitReactQuery.ts | 11 ++- apps/web/src/routes/_chat.settings.tsx | 56 ++++++++++++++ packages/contracts/src/git.test.ts | 4 +- packages/contracts/src/git.ts | 1 + packages/shared/src/git.test.ts | 71 +++++++++++++++++ packages/shared/src/git.ts | 76 +++++++++++++++++++ 13 files changed, 357 insertions(+), 55 deletions(-) create mode 100644 packages/shared/src/git.test.ts diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cc80eda23..60c8ceffc 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -462,7 +462,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe function preparePullRequestThread( manager: GitManagerShape, - input: { cwd: string; reference: string; mode: "local" | "worktree" }, + input: { cwd: string; reference: string; mode: "local" | "worktree"; branchPrefix?: string }, ) { return manager.preparePullRequestThread(input); } @@ -1763,6 +1763,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses a custom prefix for cross-repo PR worktree branches", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-custom-prefix.txt"), "fork custom prefix\n"); + yield* runGit(repoDir, ["add", "fork-custom-prefix.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork custom prefix branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 93, + title: "Fork main custom prefix PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/93", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "93", + mode: "worktree", + branchPrefix: "custom/team", + }); + + expect(result.branch).toBe("custom/team/pr-93/main"); + expect(result.worktreePath).not.toBeNull(); + expect( + (yield* runGit(result.worktreePath as string, ["branch", "--show-current"])).stdout.trim(), + ).toBe("custom/team/pr-93/main"); + }), + ); + it.effect( "does not overwrite an existing local main branch when preparing a fork PR worktree", () => diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 97760e2d3..edadc68ac 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -4,7 +4,7 @@ import { realpathSync } from "node:fs"; import { Effect, FileSystem, Layer, Path } from "effect"; import { resolveAutoFeatureBranchName, - sanitizeBranchFragment, + resolvePullRequestWorktreeLocalBranchName, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; @@ -81,18 +81,6 @@ function resolveHeadRepositoryNameWithOwner( return `${ownerLogin}/${repositoryName}`; } -function resolvePullRequestWorktreeLocalBranchName( - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, -): string { - if (!pullRequest.isCrossRepository) { - return pullRequest.headBranch; - } - - const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); - const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; - return `t3code/pr-${pullRequest.number}/${suffix}`; -} - function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { @@ -872,8 +860,14 @@ export const makeGitManager = Effect.gen(function* () { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName({ + number: pullRequestWithRemoteInfo.number, + headBranch: pullRequestWithRemoteInfo.headBranch, + ...(pullRequestWithRemoteInfo.isCrossRepository !== undefined + ? { isCrossRepository: pullRequestWithRemoteInfo.isCrossRepository } + : {}), + ...(input.branchPrefix ? { branchPrefix: input.branchPrefix } : {}), + }); const findLocalHeadBranch = (cwd: string) => gitCore.listBranches({ cwd }).pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8de44d78f..f3810f54d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -171,14 +171,13 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn((() => Effect.fail( new TextGenerationError({ operation: "generateBranchName", detail: "disabled in test harness", }), - ), - ); + )) as TextGenerationShape["generateBranchName"]); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -848,4 +847,56 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.activeTurnId).toBeNull(); }); + + it("preserves a custom temporary worktree prefix when renaming the first-turn branch", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.generateBranchName.mockImplementation(() => + Effect.succeed({ + branch: "feat/refine-toolbar", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-custom-prefix"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "custom/team/1a2b3c4d", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + 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: "refine the branch toolbar interactions", + 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: "custom/team/1a2b3c4d", + newBranch: "custom/team/feat/refine-toolbar", + }); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return thread?.branch === "custom/team/feat/refine-toolbar"; + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe0218845..7bfa8e711 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -14,6 +14,11 @@ import { } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + buildGeneratedWorktreeBranchName, + extractTemporaryWorktreeBranchPrefix, + isTemporaryWorktreeBranch, +} from "@t3tools/shared/git"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; @@ -71,8 +76,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 +93,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; @@ -402,7 +378,10 @@ const make = Effect.gen(function* () { Effect.flatMap((generated) => { if (!generated) return Effect.void; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName( + generated.branch, + extractTemporaryWorktreeBranchPrefix(oldBranch), + ); if (targetBranch === oldBranch) return Effect.void; return Effect.flatMap( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb2..2c12e9c67 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; +import { DEFAULT_WORKTREE_BRANCH_PREFIX, normalizeWorktreeBranchPrefix } from "@t3tools/shared/git"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; @@ -24,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + worktreeBranchPrefix: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_WORKTREE_BRANCH_PREFIX)), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -71,6 +75,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + worktreeBranchPrefix: normalizeWorktreeBranchPrefix(settings.worktreeBranchPrefix), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..335f8c896 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -29,6 +29,7 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; +import { normalizeWorktreeBranchPrefix } from "@t3tools/shared/git"; import { memo, useCallback, @@ -264,7 +265,6 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); @@ -430,10 +430,10 @@ function readFileAsDataUrl(file: File): Promise { }); } -function buildTemporaryWorktreeBranchName(): string { +function buildTemporaryWorktreeBranchName(prefix: string): string { // Keep the 8-hex suffix shape for backend temporary-branch detection. const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${normalizeWorktreeBranchPrefix(prefix)}/${token}`; } function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment { @@ -2659,7 +2659,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { beginSendPhase("preparing-worktree"); - const newBranch = buildTemporaryWorktreeBranchName(); + const newBranch = buildTemporaryWorktreeBranchName(settings.worktreeBranchPrefix); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, @@ -4122,6 +4122,7 @@ export default function ChatView({ threadId }: ChatViewProps) { key={pullRequestDialogState.key} open cwd={activeProject?.cwd ?? null} + branchPrefix={settings.worktreeBranchPrefix} initialReference={pullRequestDialogState.initialReference} onOpenChange={(open) => { if (!open) { diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 8b1ed5122..0e8703687 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -25,6 +25,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; cwd: string | null; + branchPrefix: string; initialReference: string | null; onOpenChange: (open: boolean) => void; onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, cwd, + branchPrefix, initialReference, onOpenChange, onPrepared, @@ -130,6 +132,7 @@ export function PullRequestThreadDialog({ const result = await preparePullRequestThreadMutation.mutateAsync({ reference: parsedReference, mode, + branchPrefix, }); await onPrepared({ branch: result.branch, @@ -141,6 +144,7 @@ export function PullRequestThreadDialog({ } }, [ + branchPrefix, cwd, onOpenChange, onPrepared, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index fe0c4705b..2b0f1df24 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -196,13 +196,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { + mutationFn: async ({ + reference, + mode, + branchPrefix, + }: { + reference: string; + mode: "local" | "worktree"; + branchPrefix?: string; + }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, reference, mode, + ...(branchPrefix ? { branchPrefix } : {}), }); }, mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 93e074442..e12e7791d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; +import { DEFAULT_WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; @@ -97,6 +98,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const worktreeBranchPrefix = settings.worktreeBranchPrefix; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -304,6 +306,60 @@ function SettingsRouteView() { +
+
+

Git

+

+ Configure how T3 Code names new worktree branches and dedicated cross-repo PR + worktree branches. +

+
+ +
+ + +
+
+

Default prefix

+

+ {DEFAULT_WORKTREE_BRANCH_PREFIX} +

+
+ {worktreeBranchPrefix !== defaults.worktreeBranchPrefix ? ( + + ) : null} +
+
+
+

Models

diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index b3775504f..ef6a3a071 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -27,15 +27,17 @@ describe("GitCreateWorktreeInput", () => { }); describe("GitPreparePullRequestThreadInput", () => { - it("accepts pull request references and mode", () => { + it("accepts pull request references, mode, and an optional branch prefix", () => { const parsed = decodePreparePullRequestThreadInput({ cwd: "/repo", reference: "#42", mode: "worktree", + branchPrefix: "custom/team", }); expect(parsed.reference).toBe("#42"); expect(parsed.mode).toBe("worktree"); + expect(parsed.branchPrefix).toBe("custom/team"); }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 34ab11b16..0a7a44d6a 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -87,6 +87,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, mode: GitPreparePullRequestThreadMode, + branchPrefix: Schema.optional(TrimmedNonEmptyStringSchema), }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts new file mode 100644 index 000000000..e0f5af6f1 --- /dev/null +++ b/packages/shared/src/git.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_WORKTREE_BRANCH_PREFIX, + buildGeneratedWorktreeBranchName, + extractTemporaryWorktreeBranchPrefix, + isTemporaryWorktreeBranch, + normalizeWorktreeBranchPrefix, + resolvePullRequestWorktreeLocalBranchName, +} from "./git"; + +describe("normalizeWorktreeBranchPrefix", () => { + it("falls back to the default prefix when the input is empty", () => { + expect(normalizeWorktreeBranchPrefix("")).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); + expect(normalizeWorktreeBranchPrefix(" ")).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); + expect(normalizeWorktreeBranchPrefix(null)).toBe(DEFAULT_WORKTREE_BRANCH_PREFIX); + }); + + it("preserves slash-separated namespaces while sanitizing invalid characters", () => { + expect(normalizeWorktreeBranchPrefix(" Team/Feature Branch ")).toBe("team/feature-branch"); + }); +}); + +describe("extractTemporaryWorktreeBranchPrefix", () => { + it("detects temporary worktree branches and returns their prefix", () => { + expect(extractTemporaryWorktreeBranchPrefix("custom/team/1a2b3c4d")).toBe("custom/team"); + expect(isTemporaryWorktreeBranch("custom/team/1a2b3c4d")).toBe(true); + }); + + it("ignores non-temporary branches", () => { + expect(extractTemporaryWorktreeBranchPrefix("custom/team/feature-branch")).toBeNull(); + expect(isTemporaryWorktreeBranch("custom/team/feature-branch")).toBe(false); + }); +}); + +describe("buildGeneratedWorktreeBranchName", () => { + it("reuses the configured prefix for generated branch names", () => { + expect(buildGeneratedWorktreeBranchName("feat/Branch Name", "custom/team")).toBe( + "custom/team/feat/branch-name", + ); + }); + + it("strips a duplicate prefix before reapplying it", () => { + expect(buildGeneratedWorktreeBranchName("custom/team/feat/example", "custom/team")).toBe( + "custom/team/feat/example", + ); + }); +}); + +describe("resolvePullRequestWorktreeLocalBranchName", () => { + it("keeps local PR branches unchanged for same-repo pull requests", () => { + expect( + resolvePullRequestWorktreeLocalBranchName({ + number: 42, + headBranch: "feature/pr-thread", + isCrossRepository: false, + }), + ).toBe("feature/pr-thread"); + }); + + it("names cross-repo PR worktree branches with the configured prefix", () => { + expect( + resolvePullRequestWorktreeLocalBranchName({ + number: 42, + headBranch: "main", + isCrossRepository: true, + branchPrefix: "custom/team", + }), + ).toBe("custom/team/pr-42/main"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index bbd290393..1ef0c6f28 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,3 +1,5 @@ +export const DEFAULT_WORKTREE_BRANCH_PREFIX = "t3code"; + /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. @@ -20,6 +22,80 @@ export function sanitizeBranchFragment(raw: string): string { return branchFragment.length > 0 ? branchFragment : "update"; } +/** + * Sanitize a worktree branch namespace/prefix while preserving slash-separated scopes. + * Falls back to the default prefix when the input is empty after normalization. + */ +export function normalizeWorktreeBranchPrefix(raw: string | null | undefined): string { + 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 && prefix.length > 0 ? prefix : DEFAULT_WORKTREE_BRANCH_PREFIX; +} + +export function extractTemporaryWorktreeBranchPrefix(branch: string): string | null { + const normalized = branch + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, ""); + const match = /^(?.+)\/(?[0-9a-f]{8})$/u.exec(normalized); + const prefix = match?.groups?.prefix?.trim(); + if (!prefix) { + return null; + } + return normalizeWorktreeBranchPrefix(prefix); +} + +export function isTemporaryWorktreeBranch(branch: string): boolean { + return extractTemporaryWorktreeBranchPrefix(branch) !== null; +} + +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)}`; +} + +export function resolvePullRequestWorktreeLocalBranchName(input: { + number: number; + headBranch: string; + isCrossRepository?: boolean; + branchPrefix?: string | null | undefined; +}): string { + if (!input.isCrossRepository) { + return input.headBranch; + } + + const prefix = normalizeWorktreeBranchPrefix(input.branchPrefix); + const suffix = sanitizeBranchFragment(input.headBranch).trim(); + return `${prefix}/pr-${input.number}/${suffix.length > 0 ? suffix : "head"}`; +} + /** * Sanitize a string into a `feature/…` branch name. * Preserves an existing `feature/` prefix or slash-separated namespace.