diff --git a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx
index 40a6f0b94c..6980de4792 100644
--- a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx
@@ -17,7 +17,7 @@ export function useBranchCommands() {
let { data: branches } = useBranchesQuery(
{
- projectRef: selectedProject?.parentRef,
+ projectRef: selectedProject?.parent_project_ref || selectedProject?.ref,
},
{ enabled: isBranchingEnabled }
)
diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx
index d6ccd43797..12d6633969 100644
--- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx
@@ -189,7 +189,7 @@ export const BranchRow = ({
},
}}
>
-
+
{branch.name}
diff --git a/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx
new file mode 100644
index 0000000000..9a04a071d0
--- /dev/null
+++ b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx
@@ -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
+
+ if (error)
+ return (
+
+
+
Error loading branch diff
+
+ Please try again in a few minutes and contact support if the problem persists.
+
+
+ )
+
+ if (!diffContent || diffContent.trim() === '') {
+ return (
+
+
+
No changes detected between branches
+
+ Any changes to your database schema will be shown here for review
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ Schema Changes
+
+
+
+
+
+
+
+ )
+}
+
+export default DatabaseDiffPanel
diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx
new file mode 100644
index 0000000000..8a65e3c9ca
--- /dev/null
+++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx
@@ -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(() => 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 (
+
+
+ {/* Function title */}
+
+
+
+ {functionSlug}
+
+
+
+ {/* File list sidebar will be shown instead of top tabs */}
+
+
+
+ {/* Sidebar file list */}
+
+
+ {fileInfos.map((fileInfo) => (
+ -
+
+
+ ))}
+
+
+
+ {/* Diff viewer */}
+
+
+
+
+
+
+ )
+}
+
+const EdgeFunctionsDiffPanel = ({
+ diffResults,
+ currentBranchRef,
+ mainBranchRef,
+}: EdgeFunctionsDiffPanelProps) => {
+ if (diffResults.isLoading) {
+ return
+ }
+
+ if (!diffResults.hasChanges) {
+ return (
+
+
+
No changes detected between branches
+
+ Any changes to your edge functions will be shown here for review
+
+
+ )
+ }
+
+ return (
+
+ {diffResults.addedSlugs.length > 0 && (
+
+
+ {diffResults.addedSlugs.map((slug) => (
+
+ ))}
+
+
+ )}
+
+ {diffResults.removedSlugs.length > 0 && (
+
+
+ {diffResults.removedSlugs.map((slug) => (
+
+ ))}
+
+
+ )}
+
+ {diffResults.modifiedSlugs.length > 0 && (
+
+ {diffResults.modifiedSlugs.map((slug) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default EdgeFunctionsDiffPanel
diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx
new file mode 100644
index 0000000000..73444da1e9
--- /dev/null
+++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx
@@ -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 (
+
+
+
+
{getTitle()}
+
{getDescription()}
+
+
+ {hasEdgeFunctionModifications ? (
+
+
+ }
+ className="shrink-0"
+ >
+ {isPushing ? 'Updating...' : 'Update branch'}
+
+
+
+
+ Update branch with modified functions?
+
+ 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.
+
+
+
+ Cancel
+ Update anyway
+
+
+
+ ) : (
+
}
+ className="shrink-0"
+ >
+ {isPushing ? 'Updating...' : 'Update branch'}
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx
new file mode 100644
index 0000000000..a3b02ffdfe
--- /dev/null
+++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx
@@ -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(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 ? (
+
+
+
+ ) : showSuccessIcon ? (
+
+ ) : null)
+
+ return (
+
+
+
+
+ {displayIcon}
+
+
{displayTitle}
+ {overrideDescription && (
+
+ {overrideDescription}
+
+ )}
+
+
+
+ {overrideAction}
+ {onClose && (
+ }
+ onClick={onClose}
+ className="h-5 w-5 p-0"
+ />
+ )}
+
+
+
+
+ {/* sticky gradient overlay */}
+
+ {logs ? (
+ {logs}
+ ) : (
+
+ {isLoading || isPolling ? 'Initializing workflow...' : 'Waiting for logs...'}
+
+ )}
+
+
+ )
+}
+
+export default WorkflowLogsCard
diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx
index 609931bd89..36272d090a 100644
--- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx
+++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx
@@ -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 }
)
diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
index 2021f3df78..b8e7d49c26 100644
--- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
+++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
@@ -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 = () => {
Create branch
+ {gitlessBranching &&
+ isBranchingEnabled &&
+ selectedBranch &&
+ !selectedBranch.is_default && (
+ <>
+ {
+ setOpen(false)
+ router.push(`/project/${ref}/merge`)
+ }}
+ onClick={() => setOpen(false)}
+ >
+
+
+ Review changes
+
+
+ >
+ )}
{
diff --git a/apps/studio/components/layouts/PageLayout/PageHeader.tsx b/apps/studio/components/layouts/PageLayout/PageHeader.tsx
index f32b096688..98e3fef0f7 100644
--- a/apps/studio/components/layouts/PageLayout/PageHeader.tsx
+++ b/apps/studio/components/layouts/PageLayout/PageHeader.tsx
@@ -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
diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx
index a8bc30fa78..f725e915cb 100644
--- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx
+++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx
@@ -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 && (
- {navigationItems.map((item) => (
-
- {item.href ? (
-
- {item.icon && {item.icon}}
- {item.label}
- {item.badge && {item.badge}}
-
- ) : (
-
- )}
-
- ))}
+ {navigationItems.map((item) => {
+ const isActive =
+ item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
+ return (
+
+ {item.href ? (
+
+ {item.icon && {item.icon}}
+ {item.label}
+ {item.badge && {item.badge}}
+
+ ) : (
+
+ )}
+
+ )
+ })}
)}
diff --git a/apps/studio/components/ui/DiffViewer.tsx b/apps/studio/components/ui/DiffViewer.tsx
new file mode 100644
index 0000000000..ae70313de0
--- /dev/null
+++ b/apps/studio/components/ui/DiffViewer.tsx
@@ -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) => (
+
+)
+
+export default DiffViewer
diff --git a/apps/studio/data/branches/branch-diff-query.ts b/apps/studio/data/branches/branch-diff-query.ts
new file mode 100644
index 0000000000..eba205e190
--- /dev/null
+++ b/apps/studio/data/branches/branch-diff-query.ts
@@ -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) {
+ 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>
+
+export const useBranchDiffQuery = (
+ { branchId, projectRef, includedSchemas = 'public' }: BranchDiffVariables,
+ {
+ enabled = true,
+ ...options
+ }: Omit, 'queryKey' | 'queryFn'> = {}
+) =>
+ useQuery(
+ branchKeys.diff(projectRef, branchId),
+ () => getBranchDiff({ branchId, includedSchemas }),
+ {
+ enabled: enabled && typeof branchId !== 'undefined' && branchId !== '',
+ ...options,
+ }
+ )
diff --git a/apps/studio/data/branches/branch-merge-mutation.ts b/apps/studio/data/branches/branch-merge-mutation.ts
new file mode 100644
index 0000000000..bceacc3822
--- /dev/null
+++ b/apps/studio/data/branches/branch-merge-mutation.ts
@@ -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>
+
+export const useBranchMergeMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation(
+ (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,
+ }
+ )
+}
diff --git a/apps/studio/data/branches/branch-push-mutation.ts b/apps/studio/data/branches/branch-push-mutation.ts
new file mode 100644
index 0000000000..035dbc2252
--- /dev/null
+++ b/apps/studio/data/branches/branch-push-mutation.ts
@@ -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) {
+ 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>
+
+export const useBranchPushMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation(
+ (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,
+ }
+ )
+}
diff --git a/apps/studio/data/branches/keys.ts b/apps/studio/data/branches/keys.ts
index 85ce513fd3..078b6cdb34 100644
--- a/apps/studio/data/branches/keys.ts
+++ b/apps/studio/data/branches/keys.ts
@@ -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,
}
diff --git a/apps/studio/data/database/migration-upsert-mutation.ts b/apps/studio/data/database/migration-upsert-mutation.ts
new file mode 100644
index 0000000000..2dea72dfef
--- /dev/null
+++ b/apps/studio/data/database/migration-upsert-mutation.ts
@@ -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 = {}
+ 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>
+
+export const useMigrationUpsertMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+ return useMutation(
+ (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,
+ }
+ )
+}
diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts
index af35720db6..d604b96395 100644
--- a/apps/studio/data/edge-functions/edge-function-body-query.ts
+++ b/apps/studio/data/edge-functions/edge-function-body-query.ts
@@ -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 []
}
}
diff --git a/apps/studio/data/workflow-runs/keys.ts b/apps/studio/data/workflow-runs/keys.ts
index dfb7939902..f2877fff87 100644
--- a/apps/studio/data/workflow-runs/keys.ts
+++ b/apps/studio/data/workflow-runs/keys.ts
@@ -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,
}
diff --git a/apps/studio/data/workflow-runs/workflow-run-query.ts b/apps/studio/data/workflow-runs/workflow-run-query.ts
new file mode 100644
index 0000000000..e2164d1ec0
--- /dev/null
+++ b/apps/studio/data/workflow-runs/workflow-run-query.ts
@@ -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 = (
+ { projectRef, workflowRunId }: WorkflowRunVariables,
+ { enabled = true, ...options }: UseQueryOptions = {}
+) =>
+ useQuery(
+ workflowRunKeys.detail(projectRef, workflowRunId),
+ ({ signal }) => getWorkflowRun({ workflowRunId }, signal),
+ {
+ enabled: enabled && typeof workflowRunId !== 'undefined',
+ staleTime: 0,
+ ...options,
+ }
+ )
diff --git a/apps/studio/hooks/branches/useBranchMergeDiff.ts b/apps/studio/hooks/branches/useBranchMergeDiff.ts
new file mode 100644
index 0000000000..a71789e903
--- /dev/null
+++ b/apps/studio/hooks/branches/useBranchMergeDiff.ts
@@ -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,
+ }
+}
diff --git a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts
new file mode 100644
index 0000000000..4319a90f7c
--- /dev/null
+++ b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts
@@ -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
+ removedBodiesMap: Record
+ currentBodiesMap: Record
+ mainBodiesMap: Record
+ 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 = {}
+ currentBodiesQueries.forEach((q, idx) => {
+ if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data
+ })
+
+ const mainBodiesMap: Record = {}
+ mainBodiesQueries.forEach((q, idx) => {
+ if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data
+ })
+
+ const addedBodiesMap: Record = {}
+ addedBodiesQueries.forEach((q, idx) => {
+ if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data
+ })
+
+ const removedBodiesMap: Record = {}
+ 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
diff --git a/apps/studio/hooks/branches/useWorkflowManagement.ts b/apps/studio/hooks/branches/useWorkflowManagement.ts
new file mode 100644
index 0000000000..2bd1c4e458
--- /dev/null
+++ b/apps/studio/hooks/branches/useWorkflowManagement.ts
@@ -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,
+ }
+}
diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx
new file mode 100644
index 0000000000..ec695e8e41
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/merge.tsx
@@ -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(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 = { 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 (
+
+
+
+
+
+ The branch merge feature is currently in development and will be available soon.
+
+
+
+
+
+
+ )
+ }
+
+ // If not on a preview branch or branch info unavailable, show notice
+ if (!isBranch || !currentBranch) {
+ return (
+
+
+
+
+
+ This page is only available for preview branches.
+
+
+
+
+
+ )
+ }
+
+ 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 = (
+
+ {isMergeDisabled ? (
+ }
+ >
+ Merge branch
+
+ ) : (
+ }
+ >
+ Merge branch
+
+ )}
+
+ )
+
+ const pageTitle = () => (
+
+ Merge{' '}
+
+
+
+ {currentBranch.name}
+
+ {' '}
+ into{' '}
+
+
+
+ {mainBranch?.name || 'main'}
+
+
+
+ )
+
+ const pageSubtitle = () => {
+ if (!currentBranch?.created_at) return 'Branch information unavailable'
+
+ const createdTime = dayjs(currentBranch.created_at).fromNow()
+ return `Branch created ${createdTime}`
+ }
+
+ return (
+
+
+
+ {isBranchOutOfDateOverall && !currentWorkflowRunId ? (
+
+ ) : currentWorkflowRunId ? (
+
+
+ ) : undefined
+ }
+ overrideAction={
+ hasCurrentWorkflowFailed ? (
+ }
+ className="shrink-0"
+ >
+ Create new branch
+
+ ) : undefined
+ }
+ />
+
+ ) : null}
+
+
+ {navigationItems.map((item) => {
+ const isActive =
+ item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
+ return (
+
+
+ {item.label}
+
+
+ )
+ })}
+
+
+
+
+ {currentTab === 'database' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+MergePage.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default MergePage
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dfdb226370..c6eb00d36a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)