Review and merge branch (#36795)

* allow creating branching without git

* update branching modals

* add account connections

* edit branch

* copy

* update copy

* enable branch modal changes

* add gitless branching flag

* update account connections

* merge page

* merge experiment

* update merge

* update pull requests empty state

* use diff query

* branch diffing

* diff query

* Clean up

* refinements to gitless branching

* branching merge and status

* link

* branch function diffing

* update styling

* refactor

* remove hook

* error handling

* move

* remove enable branching modal

* re-add github linker

* abstract away enable and disable

* toggle fixes

* update logic to lean on connection status

* update form logic

* sheet layout

* gitless flag

* style and workflow updates

* fix side panel size

* fix duplicate onerror

* copy changes

* refetch

* merge mutation copy

* remove import

* add cost

* allow connection details on create

* initial queries

* push button

* merge cleanup

* Fix TS issues

* Fix TS issues

* Couple of clean ups

* Revert hardcode in useFlag

* Fix TS

* layout issues and github check

* refactor

* refactor to use new field

* cleanup

* style

* failed merge

* error positioning

* refactoring merge

* workflow refactor

* hook move

* clarification with github integration

* replace branch dropdown button

* update repo picker

* updates

* remove modal

* fix small nits

* change defaults

* clean up

* disable if not gitless and no connection

* clean up

* always show workflow run id

* optimistic

* fix branch query

* fix issues

* fetch edge diff

* confirm merge

* update edge functions key

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
Co-authored-by: Alaister Young <a@alaisteryoung.com>
This commit is contained in:
Saxon Fletcher
2025-07-04 14:57:59 +10:00
committed by GitHub
parent d3ef8bf2b8
commit ea38c6d153
24 changed files with 2050 additions and 50 deletions

View File

@@ -17,7 +17,7 @@ export function useBranchCommands() {
let { data: branches } = useBranchesQuery(
{
projectRef: selectedProject?.parentRef,
projectRef: selectedProject?.parent_project_ref || selectedProject?.ref,
},
{ enabled: isBranchingEnabled }
)

View File

@@ -189,7 +189,7 @@ export const BranchRow = ({
},
}}
>
<Link href={`/project/${branch.project_ref}/branches`} title={branch.name}>
<Link href={`/project/${branch.project_ref}`} title={branch.name}>
{branch.name}
</Link>
</ButtonTooltip>

View File

@@ -0,0 +1,66 @@
import Link from 'next/link'
import { CircleAlert, Database, Wind } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from 'ui'
import DiffViewer from 'components/ui/DiffViewer'
interface DatabaseDiffPanelProps {
diffContent?: string
isLoading: boolean
error?: any
showRefreshButton?: boolean
currentBranchRef?: string
}
const DatabaseDiffPanel = ({
diffContent,
isLoading,
error,
currentBranchRef,
}: DatabaseDiffPanelProps) => {
if (isLoading) return <Skeleton className="h-64" />
if (error)
return (
<div className="p-6 text-center">
<CircleAlert size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">Error loading branch diff</h3>
<p className="text-sm text-foreground-light">
Please try again in a few minutes and contact support if the problem persists.
</p>
</div>
)
if (!diffContent || diffContent.trim() === '') {
return (
<div className="p-6 text-center">
<Wind size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">No changes detected between branches</h3>
<p className="text-sm text-foreground-light">
Any changes to your database schema will be shown here for review
</p>
</div>
)
}
return (
<Card>
<CardHeader>
<CardTitle>
<Link
href={`/project/${currentBranchRef}/database/schema`}
className="text-foreground-light flex items-center gap-2"
>
<Database strokeWidth={1.5} size={16} />
Schema Changes
</Link>
</CardTitle>
</CardHeader>
<CardContent className="p-0 h-96">
<DiffViewer language="sql" original="" modified={diffContent} />
</CardContent>
</Card>
)
}
export default DatabaseDiffPanel

View File

@@ -0,0 +1,217 @@
import { Code, Wind } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import DiffViewer from 'components/ui/DiffViewer'
import type { EdgeFunctionBodyData } from 'data/edge-functions/edge-function-body-query'
import type {
EdgeFunctionsDiffResult,
FileInfo,
FileStatus,
} from 'hooks/branches/useEdgeFunctionsDiff'
import { EMPTY_ARR } from 'lib/void'
import { basename } from 'path'
import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui'
interface EdgeFunctionsDiffPanelProps {
diffResults: EdgeFunctionsDiffResult
currentBranchRef?: string
mainBranchRef?: string
}
interface FunctionDiffProps {
functionSlug: string
currentBody: EdgeFunctionBodyData
mainBody: EdgeFunctionBodyData
currentBranchRef?: string
fileInfos: FileInfo[]
}
// Helper to canonicalize file identifiers to prevent mismatch due to differing root paths
const fileKey = (fullPath: string) => basename(fullPath)
// Helper to get the status color for file indicators
const getStatusColor = (status: FileStatus): string => {
switch (status) {
case 'added':
return 'bg-brand'
case 'removed':
return 'bg-destructive'
case 'modified':
return 'bg-warning'
case 'unchanged':
return 'bg-muted'
default:
return 'bg-muted'
}
}
const FunctionDiff = ({
functionSlug,
currentBody,
mainBody,
currentBranchRef,
fileInfos,
}: FunctionDiffProps) => {
// Get all file keys from fileInfos
const allFileKeys = useMemo(() => fileInfos.map((info) => info.key), [fileInfos])
const [activeFileKey, setActiveFileKey] = useState<string | undefined>(() => allFileKeys[0])
// Keep active tab in sync when allFileKeys changes (e.g. data fetch completes)
useEffect(() => {
if (!activeFileKey || !allFileKeys.includes(activeFileKey)) {
setActiveFileKey(allFileKeys[0])
}
}, [allFileKeys, activeFileKey])
const currentFile = currentBody.find((f) => fileKey(f.name) === activeFileKey)
const mainFile = mainBody.find((f) => fileKey(f.name) === activeFileKey)
const language = useMemo(() => {
if (!activeFileKey) return 'plaintext'
if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) return 'typescript'
if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) return 'javascript'
if (activeFileKey.endsWith('.json')) return 'json'
if (activeFileKey.endsWith('.sql')) return 'sql'
return 'plaintext'
}, [activeFileKey])
if (allFileKeys.length === 0) return null
return (
<Card>
<CardHeader className="space-y-0 px-4">
{/* Function title */}
<CardTitle>
<Link
href={`/project/${currentBranchRef}/functions/${functionSlug}`}
className="flex items-center gap-2"
>
<Code strokeWidth={1.5} size={16} className="text-foreground-light" />
{functionSlug}
</Link>
</CardTitle>
{/* File list sidebar will be shown instead of top tabs */}
</CardHeader>
<CardContent className="p-0 h-96">
<div className="flex h-full min-h-0">
{/* Sidebar file list */}
<div className="w-48 border-r bg-surface-200 flex flex-col overflow-y-auto">
<ul className="divide-y divide-border">
{fileInfos.map((fileInfo) => (
<li key={fileInfo.key} className="flex">
<button
type="button"
onClick={() => setActiveFileKey(fileInfo.key)}
className={cn(
'flex-1 text-left text-xs px-4 py-2 flex items-center gap-2',
activeFileKey === fileInfo.key
? 'bg-surface-300 text-foreground'
: 'text-foreground-light hover:bg-surface-300'
)}
>
<div
className={cn(
'w-1 h-1 rounded-full flex-shrink-0',
getStatusColor(fileInfo.status)
)}
/>
<span className="truncate">{fileInfo.key}</span>
</button>
</li>
))}
</ul>
</div>
{/* Diff viewer */}
<div className="flex-1 min-h-0">
<DiffViewer
language={language}
original={mainFile?.content || ''}
modified={currentFile?.content || ''}
/>
</div>
</div>
</CardContent>
</Card>
)
}
const EdgeFunctionsDiffPanel = ({
diffResults,
currentBranchRef,
mainBranchRef,
}: EdgeFunctionsDiffPanelProps) => {
if (diffResults.isLoading) {
return <Skeleton className="h-64" />
}
if (!diffResults.hasChanges) {
return (
<div className="p-6 text-center">
<Wind size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
<h3 className="mb-1">No changes detected between branches</h3>
<p className="text-sm text-foreground-light">
Any changes to your edge functions will be shown here for review
</p>
</div>
)
}
return (
<div className="space-y-6">
{diffResults.addedSlugs.length > 0 && (
<div>
<div className="space-y-4">
{diffResults.addedSlugs.map((slug) => (
<FunctionDiff
key={slug}
functionSlug={slug}
currentBody={diffResults.addedBodiesMap[slug]!}
mainBody={EMPTY_ARR}
currentBranchRef={currentBranchRef}
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
/>
))}
</div>
</div>
)}
{diffResults.removedSlugs.length > 0 && (
<div>
<div className="space-y-4">
{diffResults.removedSlugs.map((slug) => (
<FunctionDiff
key={slug}
functionSlug={slug}
currentBody={EMPTY_ARR}
mainBody={diffResults.removedBodiesMap[slug]!}
currentBranchRef={mainBranchRef}
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
/>
))}
</div>
</div>
)}
{diffResults.modifiedSlugs.length > 0 && (
<div className="space-y-4">
{diffResults.modifiedSlugs.map((slug) => (
<FunctionDiff
key={slug}
functionSlug={slug}
currentBody={diffResults.currentBodiesMap[slug]!}
mainBody={diffResults.mainBodiesMap[slug]!}
currentBranchRef={currentBranchRef}
fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
/>
))}
</div>
)}
</div>
)
}
export default EdgeFunctionsDiffPanel

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { Button } from 'ui'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from 'ui'
import { GitBranchIcon } from 'lucide-react'
import { Admonition } from 'ui-patterns'
interface OutOfDateNoticeProps {
isBranchOutOfDateMigrations: boolean
missingMigrationsCount: number
hasMissingFunctions: boolean
missingFunctionsCount: number
hasOutOfDateFunctions: boolean
outOfDateFunctionsCount: number
hasEdgeFunctionModifications: boolean
modifiedFunctionsCount: number
isPushing: boolean
onPush: () => void
}
export const OutOfDateNotice = ({
isBranchOutOfDateMigrations,
missingMigrationsCount,
hasMissingFunctions,
missingFunctionsCount,
hasOutOfDateFunctions,
outOfDateFunctionsCount,
hasEdgeFunctionModifications,
modifiedFunctionsCount,
isPushing,
onPush,
}: OutOfDateNoticeProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const hasOutdatedMigrations = isBranchOutOfDateMigrations && missingMigrationsCount > 0
const getTitle = () => {
if (hasOutdatedMigrations && (hasMissingFunctions || hasOutOfDateFunctions)) {
return 'Your database schema and edge functions are out of date'
} else if (hasOutdatedMigrations) {
return 'Your database schema is out of date'
} else if (hasMissingFunctions || hasOutOfDateFunctions) {
return 'Your functions are out of date'
}
return 'Branch is out of date'
}
const getDescription = () => {
return 'Update this branch to get the latest changes from the production branch.'
}
const handleUpdateClick = () => {
onPush()
}
const handleConfirmUpdate = () => {
setIsDialogOpen(false)
onPush()
}
return (
<Admonition type="warning" className="my-4">
<div className="w-full flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">{getTitle()}</h3>
<p className="text-sm text-foreground-light">{getDescription()}</p>
</div>
{hasEdgeFunctionModifications ? (
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
type="default"
loading={isPushing}
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
className="shrink-0"
>
{isPushing ? 'Updating...' : 'Update branch'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update branch with modified functions?</AlertDialogTitle>
<AlertDialogDescription>
This branch has {modifiedFunctionsCount} modified edge function
{modifiedFunctionsCount !== 1 ? 's' : ''} that will be overwritten when updating
with the latest functions from the production branch. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmUpdate}>Update anyway</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
type="default"
loading={isPushing}
onClick={handleUpdateClick}
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
className="shrink-0"
>
{isPushing ? 'Updating...' : 'Update branch'}
</Button>
)}
</div>
</Admonition>
)
}

View File

@@ -0,0 +1,127 @@
import { motion } from 'framer-motion'
import { CircleDotDashed, GitMerge, X } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
interface WorkflowRun {
id: string
status: string
branch_id?: string
check_run_id?: number | null
created_at?: string
updated_at?: string
workdir?: string | null
git_config?: unknown
}
interface WorkflowLogsCardProps {
workflowRun: WorkflowRun | null | undefined
logs: string | undefined
isLoading?: boolean
onClose?: () => void
// Override props for failed workflows
overrideTitle?: string
overrideDescription?: string
overrideIcon?: React.ReactNode
overrideAction?: React.ReactNode
}
const WorkflowLogsCard = ({
workflowRun,
logs,
isLoading = false,
onClose,
overrideTitle,
overrideDescription,
overrideIcon,
overrideAction,
}: WorkflowLogsCardProps) => {
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom when logs change
useEffect(() => {
if (scrollRef.current && logs) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [logs])
const showSuccessIcon = workflowRun?.status === 'FUNCTIONS_DEPLOYED'
const isFailed =
workflowRun?.status && ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status)
const isPolling =
workflowRun?.status !== 'FUNCTIONS_DEPLOYED' &&
(!workflowRun?.status ||
!['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status))
const displayTitle =
overrideTitle ||
(isPolling
? 'Processing...'
: showSuccessIcon
? 'Workflow completed successfully'
: isFailed
? 'Workflow failed'
: 'Workflow completed')
const displayIcon =
overrideIcon ||
(isPolling ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<CircleDotDashed size={16} strokeWidth={1.5} className="text-warning" />
</motion.div>
) : showSuccessIcon ? (
<GitMerge size={16} strokeWidth={1.5} className="text-brand" />
) : null)
return (
<Card className="bg-background overflow-hidden h-64 flex flex-col">
<CardHeader className={showSuccessIcon ? 'text-brand' : isFailed ? 'text-destructive' : ''}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{displayIcon}
<div>
<CardTitle className="text-sm font-medium">{displayTitle}</CardTitle>
{overrideDescription && (
<div className="text-sm text-foreground-light font-normal mt-0">
{overrideDescription}
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
{overrideAction}
{onClose && (
<Button
type="text"
size="tiny"
icon={<X size={12} strokeWidth={1.5} />}
onClick={onClose}
className="h-5 w-5 p-0"
/>
)}
</div>
</div>
</CardHeader>
<CardContent
ref={scrollRef}
className="overflow-hidden border-0 overflow-y-auto relative p-0"
>
{/* sticky gradient overlay */}
<div className="sticky top-0 -mb-8 h-8 bg-gradient-to-b from-background to-transparent pointer-events-none z-10" />
{logs ? (
<pre className="p-6 text-xs text-foreground-light p-0 rounded">{logs}</pre>
) : (
<pre className="p-6 text-sm text-foreground-light rounded">
{isLoading || isPolling ? 'Initializing workflow...' : 'Waiting for logs...'}
</pre>
)}
</CardContent>
</Card>
)
}
export default WorkflowLogsCard

View File

@@ -108,7 +108,7 @@ const SidePanelGitHubRepoLinker = ({ projectRef }: SidePanelGitHubRepoLinkerProp
)
const { data: existingBranches } = useBranchesQuery(
{ projectRef: selectedProject?.ref },
{ projectRef: selectedProject?.parent_project_ref || selectedProject?.ref },
{ enabled: !!selectedProject?.ref }
)

View File

@@ -2,7 +2,7 @@ import {
AlertCircle,
Check,
ChevronsUpDown,
GitBranch,
GitMerge,
ListTree,
MessageCircle,
Plus,
@@ -34,6 +34,7 @@ import {
cn,
} from 'ui'
import { sanitizeRoute } from './ProjectDropdown'
import { useFlag } from 'hooks/ui/useFlag'
const BranchLink = ({
branch,
@@ -75,13 +76,18 @@ const BranchLink = ({
export const BranchDropdown = () => {
const router = useRouter()
const { ref } = useParams()
const gitlessBranching = useFlag('gitlessBranching')
const projectDetails = useSelectedProject()
const snap = useAppStateSnapshot()
const isBranch = projectDetails?.parent_project_ref !== undefined
const projectRef =
projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef })
const projectRef = projectDetails?.parent_project_ref || ref
const {
data: branches,
isLoading,
isError,
isSuccess,
} = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) })
const isBranchingEnabled = projectDetails?.is_branch_enabled === true
const selectedBranch = branches?.find((branch) => branch.project_ref === ref)
@@ -153,6 +159,26 @@ export const BranchDropdown = () => {
<p>Create branch</p>
</div>
</CommandItem_Shadcn_>
{gitlessBranching &&
isBranchingEnabled &&
selectedBranch &&
!selectedBranch.is_default && (
<>
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={() => {
setOpen(false)
router.push(`/project/${ref}/merge`)
}}
onClick={() => setOpen(false)}
>
<Link href={`/project/${ref}/merge`} className="w-full flex items-center gap-2">
<GitMerge size={14} strokeWidth={1.5} />
Review changes
</Link>
</CommandItem_Shadcn_>
</>
)}
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={() => {

View File

@@ -15,8 +15,8 @@ import {
import { ScaffoldDescription, ScaffoldTitle } from '../Scaffold'
interface PageHeaderProps {
title?: string
subtitle?: string
title?: string | ReactNode
subtitle?: string | ReactNode
icon?: ReactNode
breadcrumbs?: Array<{
label?: string

View File

@@ -14,11 +14,12 @@ export interface NavigationItem {
icon?: ReactNode
onClick?: () => void
badge?: string
active?: boolean
}
interface PageLayoutProps {
children?: ReactNode
title?: string
title?: string | ReactNode
subtitle?: string
icon?: ReactNode
breadcrumbs?: Array<{
@@ -80,8 +81,8 @@ export const PageLayout = ({
className={cn(
'w-full mx-auto',
size === 'full' &&
(isCompact ? 'max-w-none !px-6 border-b' : 'max-w-none p!x-8 border-b'),
isCompact ? 'pt-4' : 'pt-12',
(isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 border-b'),
size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'),
navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'),
className
)}
@@ -102,38 +103,42 @@ export const PageLayout = ({
{/* Navigation section */}
{navigationItems.length > 0 && (
<NavMenu className={cn(isCompact ? 'mt-2' : 'mt-4', size === 'full' && 'border-none')}>
{navigationItems.map((item) => (
<NavMenuItem key={item.label} active={router.asPath.split('?')[0] === item.href}>
{item.href ? (
<Link
href={
item.href.includes('[ref]') && !!ref
? item.href.replace('[ref]', ref)
: item.href
}
className={cn(
'inline-flex items-center gap-2',
router.asPath === item.href && 'text-foreground'
)}
onClick={item.onClick}
>
{item.icon && <span>{item.icon}</span>}
{item.label}
{item.badge && <Badge variant="default">{item.badge}</Badge>}
</Link>
) : (
<Button
type="link"
onClick={item.onClick}
className={cn(router.pathname === item.href && 'text-foreground font-medium')}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
{item.badge && <Badge variant="default">{item.badge}</Badge>}
</Button>
)}
</NavMenuItem>
))}
{navigationItems.map((item) => {
const isActive =
item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
return (
<NavMenuItem key={item.label} active={isActive}>
{item.href ? (
<Link
href={
item.href.includes('[ref]') && !!ref
? item.href.replace('[ref]', ref)
: item.href
}
className={cn(
'inline-flex items-center gap-2',
isActive && 'text-foreground'
)}
onClick={item.onClick}
>
{item.icon && <span>{item.icon}</span>}
{item.label}
{item.badge && <Badge variant="default">{item.badge}</Badge>}
</Link>
) : (
<Button
type="link"
onClick={item.onClick}
className={cn(isActive && 'text-foreground font-medium')}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
{item.badge && <Badge variant="default">{item.badge}</Badge>}
</Button>
)}
</NavMenuItem>
)
})}
</NavMenu>
)}
</ScaffoldContainer>

View File

@@ -0,0 +1,48 @@
import { DiffEditor } from '@monaco-editor/react'
import { editor as monacoEditor } from 'monaco-editor'
interface DiffViewerProps {
/** Original/left hand side content (optional) */
original?: string
/** Modified/right hand side content */
modified: string | undefined
/** Language identifier understood by Monaco */
language: string
/** Height for the editor container */
height?: string | number
/** Whether to render diffs side-by-side */
sideBySide?: boolean
}
// Centralised set of options so all diff editors look the same
const DEFAULT_OPTIONS: monacoEditor.IStandaloneDiffEditorConstructionOptions = {
readOnly: true,
renderSideBySide: false,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: false,
padding: { top: 16, bottom: 16 },
lineNumbersMinChars: 3,
fontSize: 13,
scrollBeyondLastLine: false,
}
export const DiffViewer = ({
original = '',
modified = '',
language,
height = '100%',
sideBySide = false,
}: DiffViewerProps) => (
<DiffEditor
theme="supabase"
language={language}
height={height}
original={original}
modified={modified}
options={{ ...DEFAULT_OPTIONS, renderSideBySide: sideBySide }}
/>
)
export default DiffViewer

View File

@@ -0,0 +1,56 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
export type BranchDiffVariables = {
branchId: string
projectRef: string
includedSchemas?: string
}
export async function getBranchDiff({
branchId,
includedSchemas,
}: Pick<BranchDiffVariables, 'branchId' | 'includedSchemas'>) {
const { data: diffData, error } = await get('/v1/branches/{branch_id}/diff', {
params: {
path: { branch_id: branchId },
query: includedSchemas ? { included_schemas: includedSchemas } : undefined,
},
headers: {
Accept: 'text/plain',
},
parseAs: 'text',
})
if (error) {
handleError(error)
}
// Handle empty object responses (when no diff exists)
if (typeof diffData === 'object' && Object.keys(diffData).length === 0) {
return ''
}
return diffData || ''
}
type BranchDiffData = Awaited<ReturnType<typeof getBranchDiff>>
export const useBranchDiffQuery = (
{ branchId, projectRef, includedSchemas = 'public' }: BranchDiffVariables,
{
enabled = true,
...options
}: Omit<UseQueryOptions<BranchDiffData, ResponseError>, 'queryKey' | 'queryFn'> = {}
) =>
useQuery<BranchDiffData, ResponseError>(
branchKeys.diff(projectRef, branchId),
() => getBranchDiff({ branchId, includedSchemas }),
{
enabled: enabled && typeof branchId !== 'undefined' && branchId !== '',
...options,
}
)

View File

@@ -0,0 +1,92 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
import { getBranchDiff } from './branch-diff-query'
import { upsertMigration } from '../database/migration-upsert-mutation'
export type BranchMergeVariables = {
id: string
branchProjectRef: string
baseProjectRef: string
migration_version?: string
}
export async function mergeBranch({
id,
branchProjectRef,
baseProjectRef,
migration_version,
}: BranchMergeVariables) {
// Step 1: Get the diff output from the branch
const diffContent = await getBranchDiff({ branchId: id, includedSchemas: 'public' })
let migrationCreated = false
// Step 2: If there are changes, create a migration before merging
if (diffContent && diffContent.trim() !== '') {
// Generate a descriptive migration name based on current timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const migrationName = `branch_merge_${timestamp}`
await upsertMigration({
projectRef: branchProjectRef,
query: diffContent,
name: migrationName,
})
migrationCreated = true
}
// Step 3: Call POST /v1/branches/id/merge to merge the branch
const { data, error } = await post('/v1/branches/{branch_id}/merge', {
params: { path: { branch_id: id } },
body: { migration_version },
})
if (error) {
handleError(error)
}
return {
data,
migrationCreated,
hadChanges: diffContent && diffContent.trim() !== '',
workflowRunId: data?.workflow_run_id,
}
}
type BranchMergeData = Awaited<ReturnType<typeof mergeBranch>>
export const useBranchMergeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchMergeData, ResponseError, BranchMergeVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchMergeData, ResponseError, BranchMergeVariables>(
(vars) => mergeBranch(vars),
{
async onSuccess(data, variables, context) {
const { baseProjectRef } = variables
await queryClient.invalidateQueries(branchKeys.list(baseProjectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
let errorMessage = data.message || 'Unknown error occurred'
toast.error(`Failed to merge branch: ${errorMessage}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -0,0 +1,52 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
export type BranchPushVariables = {
id: string
projectRef: string
}
export async function pushBranch({ id }: Pick<BranchPushVariables, 'id'>) {
const { data, error } = await post('/v1/branches/{branch_id}/push', {
params: { path: { branch_id: id } },
body: {},
})
if (error) handleError(error)
return data
}
type BranchPushData = Awaited<ReturnType<typeof pushBranch>>
export const useBranchPushMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchPushData, ResponseError, BranchPushVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchPushData, ResponseError, BranchPushVariables>(
(vars) => pushBranch(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(branchKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to push branch: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -2,4 +2,6 @@ export const branchKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const,
detail: (projectRef: string | undefined, id: string | undefined) =>
['projects', projectRef, 'branches', id] as const,
diff: (projectRef: string | undefined, branchId: string | undefined) =>
['projects', projectRef, 'branch', branchId, 'diff'] as const,
}

View File

@@ -0,0 +1,70 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, put } from 'data/fetchers'
import type { ResponseError } from 'types'
import { databaseKeys } from './keys'
export type MigrationUpsertVariables = {
projectRef: string
query: string
name?: string
idempotencyKey?: string
}
export async function upsertMigration({
projectRef,
query,
name,
idempotencyKey,
}: MigrationUpsertVariables) {
const headers: Record<string, string> = {}
if (idempotencyKey) {
headers['Idempotency-Key'] = idempotencyKey
}
const body: { query: string; name?: string } = { query }
if (name) {
body.name = name
}
const { data, error } = await put('/v1/projects/{ref}/database/migrations', {
params: { path: { ref: projectRef } },
body,
headers,
})
if (error) handleError(error)
return data
}
type MigrationUpsertData = Awaited<ReturnType<typeof upsertMigration>>
export const useMigrationUpsertMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<MigrationUpsertData, ResponseError, MigrationUpsertVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<MigrationUpsertData, ResponseError, MigrationUpsertVariables>(
(vars) => upsertMigration(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(databaseKeys.migrations(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to upsert migration: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}

View File

@@ -54,10 +54,8 @@ export async function getEdgeFunctionBody(
const { files } = await parseResponse.json()
return files as EdgeFunctionFile[]
} catch (error) {
console.error('Failed to parse edge function code:', error)
throw new Error(
'Failed to parse function code. The file may be corrupted or in an invalid format.'
)
handleError(error)
return []
}
}

View File

@@ -1,3 +1,5 @@
export const workflowRunKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'workflow-runs'] as const,
detail: (projectRef: string | undefined, workflowRunId: string | undefined) =>
['projects', projectRef, 'workflow-run', workflowRunId] as const,
}

View File

@@ -0,0 +1,52 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { workflowRunKeys } from './keys'
export type WorkflowRunVariables = {
projectRef?: string
workflowRunId?: string
}
export async function getWorkflowRun(
{ workflowRunId }: WorkflowRunVariables,
signal?: AbortSignal
): Promise<{ logs: string; workflowRunId: string }> {
if (!workflowRunId) throw new Error('workflowRunId is required')
// Use the logs endpoint which fetches workflow run status
const { data, error } = await get('/platform/workflow-runs/{workflow_run_id}/logs', {
params: {
path: {
workflow_run_id: workflowRunId,
},
},
parseAs: 'text',
signal,
})
if (error) {
handleError(error)
}
// Return an object with the logs and we'll extract status from headers if needed
return { logs: data as string, workflowRunId }
}
export type WorkflowRunData = { logs: string; workflowRunId: string }
export type WorkflowRunError = ResponseError
export const useWorkflowRunQuery = <TData = WorkflowRunData>(
{ projectRef, workflowRunId }: WorkflowRunVariables,
{ enabled = true, ...options }: UseQueryOptions<WorkflowRunData, WorkflowRunError, TData> = {}
) =>
useQuery<WorkflowRunData, WorkflowRunError, TData>(
workflowRunKeys.detail(projectRef, workflowRunId),
({ signal }) => getWorkflowRun({ workflowRunId }, signal),
{
enabled: enabled && typeof workflowRunId !== 'undefined',
staleTime: 0,
...options,
}
)

View File

@@ -0,0 +1,240 @@
import { useMemo } from 'react'
import { useBranchDiffQuery } from 'data/branches/branch-diff-query'
import { useMigrationsQuery } from 'data/database/migrations-query'
import { useEdgeFunctionsDiff, type EdgeFunctionsDiffResult } from './useEdgeFunctionsDiff'
interface UseBranchMergeDiffProps {
branchId?: string
currentBranchRef?: string
parentProjectRef?: string
currentBranchConnectionString?: string
parentBranchConnectionString?: string
currentBranchCreatedAt?: string
}
export interface BranchMergeDiffResult {
// Database diff
diffContent: string | undefined
isDatabaseDiffLoading: boolean
isDatabaseDiffRefetching: boolean
databaseDiffError: any
refetchDatabaseDiff: () => void
// Edge functions diff
edgeFunctionsDiff: EdgeFunctionsDiffResult
// Migrations
currentBranchMigrations: any[] | undefined
mainBranchMigrations: any[] | undefined
refetchCurrentBranchMigrations: () => void
refetchMainBranchMigrations: () => void
// Branch state
isBranchOutOfDateMigrations: boolean
hasEdgeFunctionModifications: boolean
missingFunctionsCount: number
hasMissingFunctions: boolean
outOfDateFunctionsCount: number
hasOutOfDateFunctions: boolean
isBranchOutOfDateOverall: boolean
missingMigrationsCount: number
modifiedFunctionsCount: number
// Combined states
isLoading: boolean
hasChanges: boolean
}
export const useBranchMergeDiff = ({
branchId,
currentBranchRef,
parentProjectRef,
currentBranchConnectionString,
parentBranchConnectionString,
currentBranchCreatedAt,
}: UseBranchMergeDiffProps): BranchMergeDiffResult => {
// Get database diff
const {
data: diffContent,
isLoading: isDatabaseDiffLoading,
isRefetching: isDatabaseDiffRefetching,
error: databaseDiffError,
refetch: refetchDatabaseDiff,
} = useBranchDiffQuery(
{
branchId: branchId || '',
projectRef: parentProjectRef || '',
},
{
enabled: !!branchId && !!parentProjectRef,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
staleTime: 0,
}
)
// Get migrations for both current branch and main branch
const { data: currentBranchMigrations, refetch: refetchCurrentBranchMigrations } =
useMigrationsQuery(
{
projectRef: currentBranchRef,
connectionString: currentBranchConnectionString,
},
{
enabled: !!currentBranchRef,
staleTime: 3000,
}
)
const { data: mainBranchMigrations, refetch: refetchMainBranchMigrations } = useMigrationsQuery(
{
projectRef: parentProjectRef,
connectionString: parentBranchConnectionString,
},
{
enabled: !!parentProjectRef,
staleTime: 3000,
}
)
// Get edge functions diff
const edgeFunctionsDiff = useEdgeFunctionsDiff({
currentBranchRef,
mainBranchRef: parentProjectRef,
})
// Check if current branch is out of date with main branch (migrations)
const isBranchOutOfDateMigrations = useMemo(() => {
if (!currentBranchMigrations || !mainBranchMigrations) return false
// Get the latest migration version from main branch
const latestMainMigration = mainBranchMigrations[0] // migrations are ordered by version desc
if (!latestMainMigration) return false
// Check if current branch has this latest migration
const hasLatestMigration = currentBranchMigrations.some(
(migration) => migration.version === latestMainMigration.version
)
return !hasLatestMigration
}, [currentBranchMigrations, mainBranchMigrations])
// Check if main branch has functions that are newer than the current branch versions
const outOfDateFunctionsCount = useMemo(() => {
if (!edgeFunctionsDiff.modifiedSlugs.length) return 0
return edgeFunctionsDiff.modifiedSlugs.filter((slug) => {
// Get both main and current branch function data
const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug)
const currentFunction = edgeFunctionsDiff.currentBranchFunctions?.find(
(func) => func.slug === slug
)
if (!mainFunction || !currentFunction) return false
// Compare updated_at timestamps - if main branch function is newer, it was modified after current branch
const mainUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds
const currentUpdatedAt = currentFunction.updated_at * 1000 // Convert to milliseconds
return mainUpdatedAt > currentUpdatedAt
}).length
}, [
edgeFunctionsDiff.modifiedSlugs,
edgeFunctionsDiff.mainBranchFunctions,
edgeFunctionsDiff.currentBranchFunctions,
])
// Check if main branch has functions that are newer than the current branch versions
const hasOutOfDateFunctions = outOfDateFunctionsCount > 0
// Check if current branch has any edge function modifications (not additions/removals)
// This only includes functions where the current branch version is newer than the main branch version
const hasEdgeFunctionModifications =
edgeFunctionsDiff.modifiedSlugs.length > outOfDateFunctionsCount
// Count of removed functions that were updated on main branch after this branch was created
// Note: For removed functions, we use branch creation date since there's no current branch version to compare to
const missingFunctionsCount = useMemo(() => {
if (!currentBranchCreatedAt || !edgeFunctionsDiff.removedSlugs.length) return 0
const branchCreatedAt = new Date(currentBranchCreatedAt).getTime()
return edgeFunctionsDiff.removedSlugs.filter((slug) => {
// Access main branch function data from the original functions list
const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug)
if (!mainFunction) return false
// Check if function was updated after branch creation
const functionUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds
return functionUpdatedAt > branchCreatedAt
}).length
}, [
currentBranchCreatedAt,
edgeFunctionsDiff.removedSlugs,
edgeFunctionsDiff.mainBranchFunctions,
])
// Check if main branch has functions removed from current branch that were updated after branch creation
const hasMissingFunctions = missingFunctionsCount > 0
// Update overall out-of-date check to include newer removed functions and newer modified functions
const isBranchOutOfDateOverall =
isBranchOutOfDateMigrations || hasMissingFunctions || hasOutOfDateFunctions
// Get the count of migrations that the branch is missing
const missingMigrationsCount = useMemo(() => {
if (!currentBranchMigrations || !mainBranchMigrations || !isBranchOutOfDateMigrations) return 0
const currentVersions = new Set(currentBranchMigrations.map((m) => m.version))
return mainBranchMigrations.filter((m) => !currentVersions.has(m.version)).length
}, [currentBranchMigrations, mainBranchMigrations, isBranchOutOfDateMigrations])
// Get count of modified functions
const modifiedFunctionsCount = edgeFunctionsDiff.modifiedSlugs.length
// Check if there are any changes (database or edge functions)
const hasChanges = useMemo(() => {
// Check database changes
const hasDatabaseChanges = diffContent && diffContent.trim() !== ''
// Check edge function changes
const hasEdgeFunctionChanges = edgeFunctionsDiff.hasChanges
return hasDatabaseChanges || hasEdgeFunctionChanges
}, [diffContent, edgeFunctionsDiff.hasChanges])
const isLoading = isDatabaseDiffLoading || edgeFunctionsDiff.isLoading
return {
// Database diff
diffContent,
isDatabaseDiffLoading,
isDatabaseDiffRefetching,
databaseDiffError,
refetchDatabaseDiff,
// Edge functions diff
edgeFunctionsDiff,
// Migrations
currentBranchMigrations,
mainBranchMigrations,
refetchCurrentBranchMigrations,
refetchMainBranchMigrations,
// Branch state
isBranchOutOfDateMigrations,
hasEdgeFunctionModifications,
missingFunctionsCount,
hasMissingFunctions,
outOfDateFunctionsCount,
hasOutOfDateFunctions,
isBranchOutOfDateOverall,
missingMigrationsCount,
modifiedFunctionsCount,
// Combined states
isLoading,
hasChanges,
}
}

View File

@@ -0,0 +1,291 @@
import { useMemo, useCallback } from 'react'
import { useQueries, useQueryClient } from '@tanstack/react-query'
import { handleError } from 'data/fetchers'
import {
getEdgeFunctionBody,
type EdgeFunctionBodyData,
} from 'data/edge-functions/edge-function-body-query'
import {
useEdgeFunctionsQuery,
type EdgeFunctionsData,
} from 'data/edge-functions/edge-functions-query'
import { basename } from 'path'
import { edgeFunctionsKeys } from 'data/edge-functions/keys'
interface UseEdgeFunctionsDiffProps {
currentBranchRef?: string
mainBranchRef?: string
}
export type FileStatus = 'added' | 'removed' | 'modified' | 'unchanged'
export interface FileInfo {
key: string
status: FileStatus
}
export interface FunctionFileInfo {
[functionSlug: string]: FileInfo[]
}
export interface EdgeFunctionsDiffResult {
addedSlugs: string[]
removedSlugs: string[]
modifiedSlugs: string[]
addedBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
currentBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
functionFileInfo: FunctionFileInfo
isLoading: boolean
hasChanges: boolean
refetchCurrentBranchFunctions: () => void
refetchMainBranchFunctions: () => void
currentBranchFunctions?: EdgeFunctionsData
mainBranchFunctions?: EdgeFunctionsData
clearDiffsOptimistically: () => void
}
// Small helper around path.basename but avoids importing the full Node path lib for the browser bundle
const fileKey = (fullPath: string) => basename(fullPath)
export const useEdgeFunctionsDiff = ({
currentBranchRef,
mainBranchRef,
}: UseEdgeFunctionsDiffProps): EdgeFunctionsDiffResult => {
const queryClient = useQueryClient()
// Fetch edge functions for both branches
const {
data: currentBranchFunctions,
isLoading: isCurrentFunctionsLoading,
refetch: refetchCurrentBranchFunctions,
} = useEdgeFunctionsQuery(
{ projectRef: currentBranchRef },
{
enabled: !!currentBranchRef,
refetchOnMount: 'always',
staleTime: 30000, // 30 seconds
}
)
const {
data: mainBranchFunctions,
isLoading: isMainFunctionsLoading,
refetch: refetchMainBranchFunctions,
} = useEdgeFunctionsQuery(
{ projectRef: mainBranchRef },
{
enabled: !!mainBranchRef,
refetchOnMount: 'always',
staleTime: 30000, // 30 seconds
}
)
// Identify added / removed / overlapping functions
const {
added = [],
removed = [],
overlap = [],
} = useMemo(() => {
if (!currentBranchFunctions || !mainBranchFunctions) {
return { added: [], removed: [], overlap: [] as typeof currentBranchFunctions }
}
const currentFuncs = currentBranchFunctions ?? []
const mainFuncs = mainBranchFunctions ?? []
const added = currentFuncs.filter((c) => !mainFuncs.find((m) => m.slug === c.slug))
const removed = mainFuncs.filter((m) => !currentFuncs.find((c) => c.slug === m.slug))
const overlap = currentFuncs.filter((c) => mainFuncs.find((m) => m.slug === c.slug))
return { added, removed, overlap }
}, [currentBranchFunctions, mainBranchFunctions])
const overlapSlugs = overlap.map((f) => f.slug)
const addedSlugs = added.map((f) => f.slug)
const removedSlugs = removed.map((f) => f.slug)
// Fetch function bodies ---------------------------------------------------
const currentBodiesQueries = useQueries({
queries: overlapSlugs.map((slug) => ({
queryKey: ['edge-function-body', currentBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal),
enabled: !!currentBranchRef,
refetchOnMount: 'always' as const,
})),
})
const mainBodiesQueries = useQueries({
queries: overlapSlugs.map((slug) => ({
queryKey: ['edge-function-body', mainBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
enabled: !!mainBranchRef,
refetchOnMount: 'always' as const,
})),
})
const addedBodiesQueries = useQueries({
queries: addedSlugs.map((slug) => ({
queryKey: ['edge-function-body', currentBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal),
enabled: !!currentBranchRef,
refetchOnMount: 'always' as const,
})),
})
const removedBodiesQueries = useQueries({
queries: removedSlugs.map((slug) => ({
queryKey: ['edge-function-body', mainBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
enabled: !!mainBranchRef,
refetchOnMount: 'always' as const,
})),
})
// Flatten loading flags ----------------------------------------------------
const isLoading =
[
...currentBodiesQueries,
...mainBodiesQueries,
...addedBodiesQueries,
...removedBodiesQueries,
].some((q) => q.isLoading) ||
isCurrentFunctionsLoading ||
isMainFunctionsLoading
// Aggregate errors across all queries and handle the first encountered error.
const firstError = [
...currentBodiesQueries,
...mainBodiesQueries,
...addedBodiesQueries,
...removedBodiesQueries,
].find((q) => q.error)?.error
if (firstError) {
handleError(firstError)
}
// Build lookup maps --------------------------------------------------------
const currentBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
currentBodiesQueries.forEach((q, idx) => {
if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data
})
const mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
mainBodiesQueries.forEach((q, idx) => {
if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data
})
const addedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
addedBodiesQueries.forEach((q, idx) => {
if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data
})
const removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
removedBodiesQueries.forEach((q, idx) => {
if (q.data) removedBodiesMap[removedSlugs[idx]] = q.data
})
// Determine modified slugs and build file info -----------------------------
const modifiedSlugs: string[] = []
const functionFileInfo: FunctionFileInfo = {}
// Process overlapping functions to determine modifications and file info
overlapSlugs.forEach((slug) => {
const currentBody = currentBodiesMap[slug]
const mainBody = mainBodiesMap[slug]
if (!currentBody || !mainBody) return
const allFileKeys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name)))
const fileInfos: FileInfo[] = []
let hasModifications = false
for (const key of allFileKeys) {
const currentFile = currentBody.find((f) => fileKey(f.name) === key)
const mainFile = mainBody.find((f) => fileKey(f.name) === key)
let status: FileStatus = 'unchanged'
if (!currentFile && mainFile) {
status = 'removed'
hasModifications = true
} else if (currentFile && !mainFile) {
status = 'added'
hasModifications = true
} else if (currentFile && mainFile && currentFile.content !== mainFile.content) {
status = 'modified'
hasModifications = true
}
fileInfos.push({ key, status })
}
if (hasModifications) {
modifiedSlugs.push(slug)
}
functionFileInfo[slug] = fileInfos
})
// Add file info for added functions
addedSlugs.forEach((slug) => {
const body = addedBodiesMap[slug]
if (body) {
functionFileInfo[slug] = body.map((file) => ({
key: fileKey(file.name),
status: 'added' as FileStatus,
}))
}
})
// Add file info for removed functions
removedSlugs.forEach((slug) => {
const body = removedBodiesMap[slug]
if (body) {
functionFileInfo[slug] = body.map((file) => ({
key: fileKey(file.name),
status: 'removed' as FileStatus,
}))
}
})
const hasChanges = addedSlugs.length > 0 || removedSlugs.length > 0 || modifiedSlugs.length > 0
const clearDiffsOptimistically = useCallback(() => {
if (!currentBranchRef || !mainBranchFunctions) return
queryClient.setQueryData(edgeFunctionsKeys.list(currentBranchRef), mainBranchFunctions)
mainBranchFunctions.forEach((func) => {
const mainBody = mainBodiesMap[func.slug]
if (mainBody) {
queryClient.setQueryData(['edge-function-body', currentBranchRef, func.slug], mainBody)
}
})
}, [currentBranchRef, mainBranchFunctions, mainBodiesMap, queryClient])
return {
addedSlugs,
removedSlugs,
modifiedSlugs,
addedBodiesMap,
removedBodiesMap,
currentBodiesMap,
mainBodiesMap,
functionFileInfo,
isLoading,
hasChanges,
refetchCurrentBranchFunctions,
refetchMainBranchFunctions,
currentBranchFunctions,
mainBranchFunctions,
clearDiffsOptimistically,
}
}
export default useEdgeFunctionsDiff

View File

@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react'
import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query'
import { useWorkflowRunQuery } from 'data/workflow-runs/workflow-run-query'
interface UseWorkflowManagementProps {
workflowRunId?: string
projectRef?: string
onWorkflowComplete?: (status: string) => void
}
export const useWorkflowManagement = ({
workflowRunId,
projectRef,
onWorkflowComplete,
}: UseWorkflowManagementProps) => {
const [hasRefetched, setHasRefetched] = useState(false)
// Get workflow runs and logs
const { data: workflowRuns } = useWorkflowRunsQuery(
{ projectRef },
{
enabled: Boolean(workflowRunId),
refetchInterval: 3000,
refetchOnMount: 'always',
staleTime: 0,
}
)
const { data: workflowRunLogs } = useWorkflowRunQuery(
{ projectRef, workflowRunId },
{
refetchInterval: 2000,
refetchOnMount: 'always',
staleTime: 0,
}
)
// Find current workflow run
const currentWorkflowRun = workflowRuns?.find((run) => run.id === workflowRunId)
// Handle workflow completion
useEffect(() => {
if (!currentWorkflowRun?.status || !workflowRunId) return
const isComplete = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(
currentWorkflowRun.status
)
// Only refetch once per workflow completion
if (isComplete && !hasRefetched) {
setHasRefetched(true)
onWorkflowComplete?.(currentWorkflowRun.status)
}
}, [currentWorkflowRun?.status, workflowRunId, hasRefetched, onWorkflowComplete])
// Reset refetch flag when workflow ID changes
useEffect(() => {
setHasRefetched(false)
}, [workflowRunId])
return {
currentWorkflowRun,
workflowRunLogs: workflowRunLogs?.logs,
}
}

View File

@@ -0,0 +1,471 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/router'
import dayjs from 'dayjs'
import { useParams } from 'common'
import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useBranchesQuery } from 'data/branches/branches-query'
import { useBranchMergeMutation } from 'data/branches/branch-merge-mutation'
import { useBranchPushMutation } from 'data/branches/branch-push-mutation'
import { useBranchMergeDiff } from 'hooks/branches/useBranchMergeDiff'
import { useWorkflowManagement } from 'hooks/branches/useWorkflowManagement'
import DatabaseDiffPanel from 'components/interfaces/BranchManagement/DatabaseDiffPanel'
import EdgeFunctionsDiffPanel from 'components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
import { OutOfDateNotice } from 'components/interfaces/BranchManagement/OutOfDateNotice'
import { Badge, Button, NavMenu, NavMenuItem, cn, Alert } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { toast } from 'sonner'
import type { NextPageWithLayout } from 'types'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import { GitBranchIcon, GitMerge, Shield, ExternalLink, AlertTriangle } from 'lucide-react'
import Link from 'next/link'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { useFlag } from 'hooks/ui/useFlag'
const MergePage: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams()
const gitlessBranching = useFlag('gitlessBranching')
const project = useSelectedProject()
const [isSubmitting, setIsSubmitting] = useState(false)
const [workflowFinalStatus, setWorkflowFinalStatus] = useState<string | null>(null)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const isBranch = project?.parent_project_ref !== undefined
const parentProjectRef = project?.parent_project_ref
const parentProject = useProjectByRef(parentProjectRef)
const { data: branches } = useBranchesQuery(
{ projectRef: parentProjectRef },
{
refetchOnMount: 'always',
refetchOnWindowFocus: true,
staleTime: 0,
}
)
const currentBranch = branches?.find((branch) => branch.project_ref === ref)
const mainBranch = branches?.find((branch) => branch.is_default)
const {
diffContent,
isDatabaseDiffLoading,
isDatabaseDiffRefetching,
databaseDiffError: diffError,
refetchDatabaseDiff: refetchDiff,
edgeFunctionsDiff,
isBranchOutOfDateMigrations,
hasEdgeFunctionModifications,
missingFunctionsCount,
hasMissingFunctions,
outOfDateFunctionsCount,
hasOutOfDateFunctions,
isBranchOutOfDateOverall,
missingMigrationsCount,
modifiedFunctionsCount,
isLoading: isCombinedDiffLoading,
hasChanges: combinedHasChanges,
} = useBranchMergeDiff({
branchId: currentBranch?.id,
currentBranchRef: ref,
parentProjectRef,
currentBranchConnectionString: project?.connectionString || undefined,
parentBranchConnectionString: (parentProject as any)?.connectionString || undefined,
currentBranchCreatedAt: currentBranch?.created_at,
})
const currentWorkflowRunId = router.query.workflow_run_id as string | undefined
useEffect(() => {
setWorkflowFinalStatus(null)
}, [currentWorkflowRunId])
const { currentWorkflowRun: currentBranchWorkflow, workflowRunLogs: currentBranchLogs } =
useWorkflowManagement({
workflowRunId: currentWorkflowRunId,
projectRef: ref,
onWorkflowComplete: (status) => {
setWorkflowFinalStatus(status)
refetchDiff()
edgeFunctionsDiff.clearDiffsOptimistically()
},
})
const { currentWorkflowRun: parentBranchWorkflow, workflowRunLogs: parentBranchLogs } =
useWorkflowManagement({
workflowRunId: currentWorkflowRunId,
projectRef: parentProjectRef,
onWorkflowComplete: (status) => {
setWorkflowFinalStatus(status)
refetchDiff()
edgeFunctionsDiff.clearDiffsOptimistically()
},
})
const currentWorkflowRun = currentBranchWorkflow || parentBranchWorkflow
const workflowRunLogs = currentBranchLogs || parentBranchLogs
const hasCurrentWorkflowFailed = workflowFinalStatus
? ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowFinalStatus)
: currentWorkflowRun?.status &&
['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(currentWorkflowRun.status)
const isWorkflowRunning =
currentWorkflowRun?.status === 'RUNNING_MIGRATIONS' ||
currentWorkflowRun?.status === 'CREATING_PROJECT'
const addWorkflowRun = useCallback(
(workflowRunId: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, workflow_run_id: workflowRunId },
})
},
[router]
)
const clearWorkflowRun = useCallback(() => {
const { workflow_run_id, ...queryWithoutWorkflowId } = router.query
router.push({
pathname: router.pathname,
query: queryWithoutWorkflowId,
})
}, [router])
const { mutate: pushBranch, isLoading: isPushing } = useBranchPushMutation({
onSuccess: (data) => {
toast.success('Branch update initiated!')
if (data?.workflow_run_id) {
addWorkflowRun(data.workflow_run_id)
}
},
onError: (error) => {
toast.error(`Failed to update branch: ${error.message}`)
},
})
const { mutate: mergeBranch, isLoading: isMerging } = useBranchMergeMutation({
onSuccess: (data) => {
setIsSubmitting(false)
if (data.workflowRunId) {
toast.success('Branch merge initiated!')
addWorkflowRun(data.workflowRunId)
} else {
toast.info('No changes to merge')
}
},
onError: (error) => {
setIsSubmitting(false)
toast.error(`Failed to merge branch: ${error.message}`)
},
})
const handlePush = () => {
if (!currentBranch?.id || !parentProjectRef) return
pushBranch({
id: currentBranch.id,
projectRef: parentProjectRef,
})
}
const handleMerge = () => {
if (!currentBranch?.id || !parentProjectRef || !ref) return
setIsSubmitting(true)
mergeBranch({
id: currentBranch.id,
branchProjectRef: ref,
baseProjectRef: parentProjectRef,
migration_version: undefined,
})
}
const handleShowConfirmDialog = () => {
setShowConfirmDialog(true)
}
const handleConfirmMerge = () => {
setShowConfirmDialog(false)
handleMerge()
}
const handleCancelMerge = () => {
setShowConfirmDialog(false)
}
const breadcrumbs = useMemo(
() => [
{
label: 'Branches',
href: `/project/${parentProjectRef}/branches`,
},
],
[parentProjectRef]
)
const currentTab = (router.query.tab as string) || 'database'
const navigationItems = useMemo(() => {
const buildHref = (tab: string) => {
const query: Record<string, string> = { tab }
if (currentWorkflowRunId) query.workflow_run_id = currentWorkflowRunId
const qs = new URLSearchParams(query).toString()
return `/project/[ref]/merge?${qs}`
}
return [
{
label: 'Database',
href: buildHref('database'),
active: currentTab === 'database',
},
{
label: 'Edge Functions',
href: buildHref('edge-functions'),
active: currentTab === 'edge-functions',
},
]
}, [currentWorkflowRunId, currentTab])
if (!gitlessBranching) {
return (
<PageLayout>
<ScaffoldContainer size="full">
<div className="flex items-center flex-col justify-center w-full py-16">
<ProductEmptyState title="Branch Merge - Coming Soon">
<p className="text-sm text-foreground-light">
The branch merge feature is currently in development and will be available soon.
</p>
<div className="flex items-center space-x-2 !mt-4">
<Button type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
<a
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/guides/platform/branching"
>
View the docs
</a>
</Button>
</div>
</ProductEmptyState>
</div>
</ScaffoldContainer>
</PageLayout>
)
}
// If not on a preview branch or branch info unavailable, show notice
if (!isBranch || !currentBranch) {
return (
<PageLayout>
<ScaffoldContainer size="full">
<div className="flex items-center flex-col justify-center w-full py-16">
<ProductEmptyState title="Merge Request">
<p className="text-sm text-foreground-light">
This page is only available for preview branches.
</p>
</ProductEmptyState>
</div>
</ScaffoldContainer>
</PageLayout>
)
}
const isMergeDisabled =
!combinedHasChanges || isCombinedDiffLoading || isBranchOutOfDateOverall || isWorkflowRunning
// Update primary actions - remove push button if branch is out of date (it will be in the notice)
const primaryActions = (
<div className="flex items-end gap-2">
{isMergeDisabled ? (
<ButtonTooltip
tooltip={{
content: {
text: !combinedHasChanges
? 'No changes to merge'
: isWorkflowRunning
? 'Workflow is currently running'
: 'Branch is out of date',
},
}}
type="primary"
loading={isMerging || isSubmitting}
disabled={isMergeDisabled}
onClick={handleShowConfirmDialog}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</ButtonTooltip>
) : (
<Button
type="primary"
loading={isMerging || isSubmitting}
onClick={handleShowConfirmDialog}
disabled={isBranchOutOfDateOverall}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</Button>
)}
</div>
)
const pageTitle = () => (
<span>
Merge{' '}
<Link href={`/project/${ref}/editor`}>
<Badge className="font-mono text-lg gap-1">
<GitBranchIcon strokeWidth={1.5} size={16} className="text-foreground-muted" />
{currentBranch.name}
</Badge>
</Link>{' '}
into{' '}
<Link
href={`/project/${mainBranch?.project_ref}/editor`}
className="font-mono inline-flex gap-4"
>
<Badge className="font-mono text-lg gap-1">
<Shield strokeWidth={1.5} size={16} className="text-warning" />
{mainBranch?.name || 'main'}
</Badge>
</Link>
</span>
)
const pageSubtitle = () => {
if (!currentBranch?.created_at) return 'Branch information unavailable'
const createdTime = dayjs(currentBranch.created_at).fromNow()
return `Branch created ${createdTime}`
}
return (
<PageLayout
title={pageTitle()}
subtitle={pageSubtitle()}
breadcrumbs={breadcrumbs}
primaryActions={primaryActions}
size="full"
className="border-b-0 pb-0"
>
<div className="border-b">
<ScaffoldContainer size="full">
{isBranchOutOfDateOverall && !currentWorkflowRunId ? (
<OutOfDateNotice
isBranchOutOfDateMigrations={isBranchOutOfDateMigrations}
missingMigrationsCount={missingMigrationsCount}
hasMissingFunctions={hasMissingFunctions}
missingFunctionsCount={missingFunctionsCount}
hasOutOfDateFunctions={hasOutOfDateFunctions}
outOfDateFunctionsCount={outOfDateFunctionsCount}
hasEdgeFunctionModifications={hasEdgeFunctionModifications}
modifiedFunctionsCount={modifiedFunctionsCount}
isPushing={isPushing}
onPush={handlePush}
/>
) : currentWorkflowRunId ? (
<div className="my-6">
<WorkflowLogsCard
workflowRun={currentWorkflowRun}
logs={workflowRunLogs}
isLoading={!workflowRunLogs && !!currentWorkflowRunId}
onClose={clearWorkflowRun}
overrideTitle={hasCurrentWorkflowFailed ? 'Workflow failed' : undefined}
overrideDescription={
hasCurrentWorkflowFailed
? 'Consider creating a fresh branch from the latest production branch to resolve potential conflicts.'
: undefined
}
overrideIcon={
hasCurrentWorkflowFailed ? (
<AlertTriangle
size={16}
strokeWidth={1.5}
className="text-destructive shrink-0"
/>
) : undefined
}
overrideAction={
hasCurrentWorkflowFailed ? (
<Button
type="default"
asChild
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
className="shrink-0"
>
<Link href={`/project/${parentProjectRef}/branches`}>Create new branch</Link>
</Button>
) : undefined
}
/>
</div>
) : null}
<NavMenu className="mt-4 border-none">
{navigationItems.map((item) => {
const isActive =
item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
return (
<NavMenuItem key={item.label} active={isActive}>
<Link
href={
item.href.includes('[ref]') && !!ref
? item.href.replace('[ref]', ref)
: item.href
}
className={cn('inline-flex items-center gap-2', isActive && 'text-foreground')}
>
{item.label}
</Link>
</NavMenuItem>
)
})}
</NavMenu>
</ScaffoldContainer>
</div>
<ScaffoldContainer size="full" className="pt-6 pb-12">
{currentTab === 'database' ? (
<DatabaseDiffPanel
diffContent={diffContent}
isLoading={isDatabaseDiffLoading || isDatabaseDiffRefetching}
error={diffError}
showRefreshButton={true}
currentBranchRef={ref}
/>
) : (
<EdgeFunctionsDiffPanel
diffResults={edgeFunctionsDiff}
currentBranchRef={ref}
mainBranchRef={parentProjectRef}
/>
)}
</ScaffoldContainer>
<ConfirmationModal
visible={showConfirmDialog}
title="Confirm Branch Merge"
description={`Are you sure you want to merge "${currentBranch?.name}" into "${mainBranch?.name || 'main'}"? This action cannot be undone.`}
confirmLabel="Merge Branch"
confirmLabelLoading="Merging..."
onConfirm={handleConfirmMerge}
onCancel={handleCancelMerge}
loading={isMerging || isSubmitting}
/>
</PageLayout>
)
}
MergePage.getLayout = (page) => (
<DefaultLayout>
<ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
</DefaultLayout>
)
export default MergePage

2
pnpm-lock.yaml generated
View File

@@ -31190,7 +31190,7 @@ snapshots:
flags@4.0.1(@opentelemetry/api@1.9.0)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@edge-runtime/cookies': 5.0.2
jose: 5.2.1
jose: 5.9.6
optionalDependencies:
'@opentelemetry/api': 1.9.0
next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)