import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { useFlag, useParams } from 'common' import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { useCallback, useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Badge, Button, cn, Dialog, DialogContent, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, Form, FormControl, FormField, Input, Label, Switch, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { estimateComputeSize, estimateDiskCost, estimateRestoreTime, } from './BranchManagement.utils' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' import AlertError from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation' import { useBranchesQuery } from '@/data/branches/branches-query' import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query' import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { projectKeys } from '@/data/projects/keys' import { DesiredInstanceSize, instanceSizeSpecs } from '@/data/projects/new-project.constants' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' import { useAppStateSnapshot } from '@/state/app-state' export const CreateBranchModal = () => { const { ref } = useParams() const router = useRouter() const queryClient = useQueryClient() const { data: projectDetails } = useSelectedProjectQuery() const { data: selectedOrg } = useSelectedOrganizationQuery() const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot() const allowDataBranching = useFlag('allowDataBranching') const [isGitBranchValid, setIsGitBranchValid] = useState(false) const { can: canCreateBranch } = useAsyncCheckPermissions( PermissionAction.CREATE, 'preview_branches' ) const { hasAccess: hasAccessToBranching, isLoading: isLoadingEntitlement } = useCheckEntitlements('branching_limit') const promptPlanUpgrade = IS_PLATFORM && !hasAccessToBranching const isBranch = projectDetails?.parent_project_ref !== undefined const projectRef = projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const formId = 'create-branch-form' const FormSchema = z.object({ branchName: z .string() .min(1, 'Branch name cannot be empty') .refine( (val) => /^[a-zA-Z0-9\-_]+$/.test(val), 'Branch name can only contain alphanumeric characters, hyphens, and underscores.' ) .refine( (val) => (branches ?? []).every((branch) => branch.name !== val), 'A branch with this name already exists' ), gitBranchName: z.string().optional(), withData: z.boolean().default(false).optional(), }) const form = useForm>({ mode: 'onSubmit', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), defaultValues: { branchName: '', gitBranchName: '', withData: false }, }) const { withData, gitBranchName } = form.watch() const debouncedGitBranchName = useDebounce(gitBranchName, 500) const { data: connections, error: connectionsError, isPending: isLoadingConnections, isSuccess: isSuccessConnections, isError: isErrorConnections, } = useGitHubConnectionsQuery( { organizationId: selectedOrg?.id }, { enabled: showCreateBranchModal } ) const { data: branches } = useBranchesQuery({ projectRef }) const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery( { projectRef }, { enabled: showCreateBranchModal } ) const computeAddon = addons?.selected_addons.find((addon) => addon.type === 'compute_instance') const computeSize = computeAddon ? (computeAddon.variant.identifier.split('ci_')[1] as DesiredInstanceSize) : undefined const hasPitrEnabled = (addons?.selected_addons ?? []).some((addon) => addon.type === 'pitr') const { data: disk, isPending: isLoadingDiskAttr, isError: isErrorDiskAttr, } = useDiskAttributesQuery({ projectRef }, { enabled: showCreateBranchModal && withData }) const projectDiskAttributes = disk?.attributes ?? { type: 'gp3', size_gb: 0, iops: 0, throughput_mbps: 0, } // Branch disk is oversized to include backup files, it should be scaled back eventually. const branchDiskAttributes = { ...projectDiskAttributes, // [Joshen] JFYI for Qiao - this multiplier may eventually be dropped size_gb: Math.round(projectDiskAttributes.size_gb * 1.5), } as DiskAttributesData['attributes'] const branchComputeSize = estimateComputeSize(projectDiskAttributes.size_gb, computeSize) const estimatedDiskCost = estimateDiskCost(branchDiskAttributes) const track = useTrack() const { mutate: checkGithubBranchValidity, isPending: isCheckingGHBranchValidity } = useCheckGithubBranchValidity({ onError: () => {}, }) const { mutate: createBranch, isPending: isCreatingBranch } = useBranchCreateMutation({ onSuccess: async (data) => { toast.success(`Successfully created preview branch "${data.name}"`) if (projectRef) { await queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectRef) }) } track('branch_create_button_clicked', { branchType: data.persistent ? 'persistent' : 'preview', gitlessBranching: !data.git_branch, }) setShowCreateBranchModal(false) router.push(`/project/${data.project_ref}`) }, onError: (error) => { toast.error(`Failed to create branch: ${error.message}`) }, }) // Fetch production/default branch to inspect git_branch linkage const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) const prodBranch = branches?.find((branch) => branch.is_default) const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid) const isDisabled = !isFormValid || !canCreateBranch || !isSuccessAddons || (!!gitBranchName && !isSuccessConnections) || isLoadingEntitlement || !hasAccessToBranching || isCreatingBranch || isCheckingGHBranchValidity const tooltipText = promptPlanUpgrade ? 'Upgrade to unlock branching' : undefined const validateGitBranchName = useCallback( (branchName: string) => { if (!githubConnection) { return console.error( '[CreateBranchModal > validateGitBranchName] GitHub Connection is missing' ) } const repositoryId = githubConnection.repository.id checkGithubBranchValidity( { repositoryId, branchName }, { onSuccess: () => { if (form.getValues('gitBranchName') !== branchName) return // Check if another branch is already linked to this git branch const existingBranch = (branches ?? []).find((b) => b.git_branch === branchName) if (existingBranch) { setIsGitBranchValid(false) form.setError('gitBranchName', { message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`, }) return } setIsGitBranchValid(true) form.clearErrors('gitBranchName') }, onError: (error) => { if (form.getValues('gitBranchName') !== branchName) return setIsGitBranchValid(false) form.setError('gitBranchName', { message: error?.message ?? `Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`, }) }, } ) }, [githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches] ) const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') createBranch({ projectRef, branchName: data.branchName, is_default: false, ...(data.withData ? { desired_instance_size: computeSize } : {}), ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}), ...(allowDataBranching ? { withData: data.withData } : {}), }) } const handleGitHubClick = () => { setShowCreateBranchModal(false) router.push(`/project/${projectRef}/settings/integrations`) } useEffect(() => { if (showCreateBranchModal) form.reset() }, [form, showCreateBranchModal]) useEffect(() => { form.clearErrors('gitBranchName') if (githubConnection && debouncedGitBranchName) validateGitBranchName(debouncedGitBranchName) }, [debouncedGitBranchName, validateGitBranchName, form, githubConnection]) return ( { if (promptPlanUpgrade) e.preventDefault() }} aria-describedby={undefined} > Create a new preview branch
{promptPlanUpgrade && ( )} ( )} /> {isLoadingConnections && (
)} {isErrorConnections && ( )} {isSuccessConnections && (githubConnection ? ( ( Sync with Git branch
{`GitHub {repoOwner}/{repoName}
} labelOptional="Optional" description="Automatically deploy changes on every commit" >
{ field.onChange(e) setIsGitBranchValid(false) }} />
{field.value ? ( isCheckingGHBranchValidity ? ( ) : isGitBranchValid ? ( ) : null ) : null}
)} /> ) : (

Keep this preview branch in sync with a chosen GitHub branch

))} {allowDataBranching && ( ( {!hasPitrEnabled && Requires PITR} } layout="flex-row-reverse" className="[&>div>label]:mb-1" description="Clone production data into this branch" > )} /> )}
{withData && (
{isLoadingDiskAttr ? ( <> ) : ( <> {isErrorDiskAttr ? ( <>

Branch disk size will incur additional cost per month

The additional cost and time taken to create a data branch is relative to the size of your database. We are unable to provide an estimate as we were unable to retrieve your project's disk configuration

) : ( <>

Branch disk size is billed at ${estimatedDiskCost.total.toFixed(2)}{' '} per month

Creating a data branch will take about{' '} {estimateRestoreTime(branchDiskAttributes).toFixed()} minutes {' '} and costs{' '} ${estimatedDiskCost.total.toFixed(2)} {' '} per month based on your current target database volume size of{' '} {branchDiskAttributes.size_gb} GB and your{' '} project's disk configuration

Disk type:

{branchDiskAttributes.type.toUpperCase()}

Target disk size:

{branchDiskAttributes.size_gb} GB

(${estimatedDiskCost.size.toFixed(2)})

IOPs:

{branchDiskAttributes.iops} IOPS

(${estimatedDiskCost.iops.toFixed(2)})

{'throughput_mbps' in branchDiskAttributes && (

Throughput:

{branchDiskAttributes.throughput_mbps} MB/s

(${estimatedDiskCost.throughput.toFixed(2)})

)}

More info in{' '} setShowCreateBranchModal(false)} className="pointer-events-auto" href={`/project/${ref}/settings/compute-and-disk`} > Compute and Disk

.

)} )}
)} {githubConnection && (

{prodBranch?.git_branch ? 'Merging to production enabled' : 'Merging to production disabled'}

{prodBranch?.git_branch ? ( <> When this branch is merged to{' '} {prodBranch.git_branch}, migrations will be deployed to production. Otherwise, migrations only run on preview branches. ) : ( <> Merging this branch to production will not deploy migrations. To enable production deployment, enable "Deploy to production" in project integration settings. )}

)}

Branch compute is billed at $ {withData ? branchComputeSize.priceHourly : instanceSizeSpecs.micro.priceHourly}{' '} per hour

{withData ? ( <> {branchComputeSize.label} compute size is automatically selected to match your production branch. You may downgrade after creation or pause the branch when not in use to save cost. ) : ( <>This cost will continue for as long as the branch has not been removed. )}

{!hasPitrEnabled && }
Create branch
) }