Files
windmill/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte
T
Ruben FiszelandClaude Opus 5 8c6211c277 feat: offer more dev workspace environment labels (#10570)
* feat: allow custom dev workspace environment labels

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reject dev labels that shadow a tracked branch's namespace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard dev labels against a repo's assumed default branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: state the badge-cap rationale once and drop unenforceable openapi constraints

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: offer a fixed list of environment labels instead of free text

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match the accepted label set to the openapi enum exactly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: stop describing the label set as dev/staging only

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:32:19 +00:00

1000 lines
35 KiB
Svelte

<script lang="ts">
import {
Save,
Trash,
XCircle,
CheckCircle2,
RotateCw,
RotateCcw,
Download,
Upload,
Plus
} from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import { getGitSyncContext } from './GitSyncContext.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import DetectionFlow from './DetectionFlow.svelte'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { fade } from 'svelte/transition'
import { workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
import type { GitSyncRepository } from './GitSyncContext.svelte'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import EEOnly from '$lib/components/EEOnly.svelte'
import { ResourceService, VariableService } from '$lib/gen'
let {
idx = null,
isSecondary = false,
isLegacy = false,
variant = 'standard',
mode = null,
repository = null,
onAdd = null,
isCollapsible = true,
showEmptyState = false,
devPromotion = false
} = $props<{
idx?: number | null
isSecondary?: boolean
isLegacy?: boolean
variant?: 'primary-sync' | 'primary-promotion' | 'secondary' | 'legacy' | 'standard'
mode?: 'sync' | 'promotion' | null
repository?: GitSyncRepository | null
onAdd?: (() => void) | null
isCollapsible?: boolean
showEmptyState?: boolean
// Dev workspace: this is the single inherited repo, and promotion is a
// toggle on it (reuse prod's repo) rather than a separately-configured one.
devPromotion?: boolean
}>()
const gitSyncContext = getGitSyncContext()
const repo = $derived(repository || (idx !== null ? gitSyncContext.getRepository(idx) : null))
const validation = $derived(idx !== null ? gitSyncContext.getValidation(idx) : null)
const gitSyncTestJob = $derived(idx !== null ? gitSyncContext.gitSyncTestJobs?.[idx] : null)
let confirmingDelete = $state(false)
// Enable/disable automatic repo → workspace pulls, managing the optional
// auto_pull object without binding into a possibly-undefined value.
function setAutoPullEnabled(enabled: boolean) {
if (!repo) return
if (enabled) {
repo.auto_pull = {
...(repo.auto_pull ?? {}),
enabled: true,
mode: repo.auto_pull?.mode ?? 'auto',
sync_forks: repo.auto_pull?.sync_forks ?? true
}
} else if (repo.auto_pull) {
repo.auto_pull = { ...repo.auto_pull, enabled: false }
}
}
// Parent-level fork auto-sync (phase 5). Configured on the parent workspace's
// repo and applied to all of its forks, so hide it when the current workspace
// is itself a fork.
const currentWorkspaceData = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
// A fork or dev workspace: has a parent, or carries the wm-fork- id prefix
// (which survives if the parent is deleted). Mirrors the backend/CLI rule.
const isFork = $derived(
($workspaceStore?.startsWith('wm-fork-') ?? false) ||
!!currentWorkspaceData?.parent_workspace_id
)
// A dev workspace is a fork that DOES run promotion mode (per-item
// wm_deploy/** branches into its parent), unlike a throwaway fork.
const isDevWorkspace = $derived(!!currentWorkspaceData?.is_dev_workspace)
function setSyncForks(v: boolean) {
if (repo?.auto_pull) repo.auto_pull = { ...repo.auto_pull, sync_forks: v }
}
function setForkOpenPrs(v: boolean) {
if (repo) repo.fork_open_prs = v
}
function setPromotionOpenPrs(v: boolean) {
if (repo) repo.promotion_open_prs = v
}
// The promotion toggles persist immediately and must not overlap: concurrent
// whole-repository saves can complete out of order (enabling runs extra
// backend checks), letting a stale earlier state overwrite the latest one.
let savingDevPromotion = $state(false)
async function setDevPromotion(v: boolean) {
if (!repo || idx === null || savingDevPromotion) return
const prevIndiv = repo.use_individual_branch
const prevGbf = repo.group_by_folder
repo.use_individual_branch = v
if (!v) repo.group_by_folder = false
savingDevPromotion = true
try {
await gitSyncContext.saveRepository(idx)
} catch (e) {
// The backend rejects promotion mode without an active EE plan; revert
// the optimistic toggle instead of leaving it stuck on until reload.
if (repo) {
repo.use_individual_branch = prevIndiv
repo.group_by_folder = prevGbf
}
sendUserToast(
`Could not ${v ? 'enable' : 'disable'} Git promotion: ${apiErrorMessage(e)}`,
true
)
} finally {
savingDevPromotion = false
}
}
async function setGroupByFolder(v: boolean) {
if (!repo || idx === null || savingDevPromotion) return
const prev = repo.group_by_folder
repo.group_by_folder = v
savingDevPromotion = true
try {
await gitSyncContext.saveRepository(idx)
} catch (e) {
if (repo) repo.group_by_folder = prev
sendUserToast(`Could not change promotion granularity: ${apiErrorMessage(e)}`, true)
} finally {
savingDevPromotion = false
}
}
let targetBranch = $state<string | undefined>(undefined) // Default to main, will be updated when resource is available
// The branch this fork workspace syncs with, mirroring the CLI/hub-script
// naming: a dev workspace uses its environment-label branch verbatim
// (dev, staging, ...); a wm-fork-<slug> throwaway fork keeps only the slug.
const forkBranch = $derived(
currentWorkspaceData?.is_dev_workspace
? (currentWorkspaceData?.dev_workspace_label ?? 'dev')
: `wm-fork/${targetBranch ?? 'main'}/${($workspaceStore ?? '').replace(/^wm-fork-/, '')}`
)
let resourceInfo = $state<{ url?: string; error?: string } | null>(null)
let loadingResourceInfo = $state(false)
// Only GitHub App-backed repos can register webhooks; PAT repos poll only.
let isGithubApp = $state(false)
// Update target branch when repository changes
$effect(() => {
const abortController = new AbortController()
if (repo?.git_repo_resource_path) {
gitSyncContext
.getTargetBranch(repo)
.then((branch) => {
if (!abortController.signal.aborted) {
targetBranch = branch
}
})
.catch((error) => {
if (!abortController.signal.aborted) {
console.warn('Failed to get target branch:', error)
}
})
}
return () => {
abortController.abort()
}
})
// Load resource info when resource path is set and connection is saved (disabled)
$effect(() => {
const abortController = new AbortController()
async function loadResourceInfo() {
if (repo?.git_repo_resource_path && $workspaceStore) {
loadingResourceInfo = true
resourceInfo = null
// Clear stale app state up front so a resource change or a failed
// fetch can't leave webhook/fork controls showing for the wrong repo.
isGithubApp = false
try {
const resource = await ResourceService.getResource({
workspace: $workspaceStore,
path: repo.git_repo_resource_path
})
if (!abortController.signal.aborted && resource?.value) {
// Extract git URL from resource value
const value = resource.value as Record<string, any>
isGithubApp = value?.is_github_app === true
// A newly added sync connection defaults to pulling from Git only
// when the repository is app-backed (instant webhook delivery).
// Polling is opt-in for token repositories, and fork/dev workspaces
// never get the parent-only defaults (the backend rejects them).
// EE-only.
if (
repoMode === 'sync' &&
repo.isUnsavedConnection &&
isGithubApp &&
!isFork &&
$enterpriseLicense &&
repo.auto_pull === undefined
) {
repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true }
}
// Promotion deploys push wm_deploy/** branches that exist to be
// merged; without a PR the deploy is an orphaned branch. Default
// the managed PR on where Windmill can open it (app-backed).
// Fork PRs stay opt-in everywhere.
if (
repoMode === 'promotion' &&
repo.isUnsavedConnection &&
isGithubApp &&
$enterpriseLicense &&
repo.promotion_open_prs === undefined
) {
repo.promotion_open_prs = true
}
// Webhook with polling fallback is the only delivery for app repos.
if (isGithubApp && repo.auto_pull?.mode === 'polling') {
repo.auto_pull = { ...repo.auto_pull, mode: 'auto' }
}
let gitUrl = value?.url || value?.git_url
if (gitUrl && typeof gitUrl === 'string') {
// Check if the URL is a variable reference ($var:path)
const varMatch = gitUrl.match(/^\$var:(.+)$/)
if (varMatch) {
const varPath = varMatch[1]
try {
// Attempt to fetch the variable value
const variable = await VariableService.getVariable({
workspace: $workspaceStore,
path: varPath,
decryptSecret: true
})
if (!abortController.signal.aborted && variable?.value) {
gitUrl = variable.value
} else {
// Variable doesn't have a value or couldn't be fetched
// Don't display anything
resourceInfo = null
return
}
} catch (error) {
// Failed to fetch variable (permissions, not found, etc.)
// Don't display anything
if (!abortController.signal.aborted) {
console.debug('Failed to fetch variable for git URL:', error)
resourceInfo = null
}
return
}
}
// Mask password in URL (supports https://user:password@host and https://token@host patterns)
const maskedUrl = gitUrl.replace(
/(https?:\/\/)([^:@]+)(:([^@]+))?@/,
(match, protocol, user, colonPassword, password) => {
if (password) {
return `${protocol}${user}:${'*'.repeat(8)}@`
}
return `${protocol}${'*'.repeat(8)}@`
}
)
resourceInfo = { url: maskedUrl }
} else {
resourceInfo = { error: 'Git URL not found in resource' }
}
}
} catch (error) {
if (!abortController.signal.aborted) {
console.error('Failed to load resource info:', error)
resourceInfo = { error: 'Failed to load resource info' }
}
} finally {
if (!abortController.signal.aborted) {
loadingResourceInfo = false
}
}
} else {
resourceInfo = null
isGithubApp = false
}
}
loadResourceInfo()
return () => {
abortController.abort()
}
})
// Compute already-used repository paths to exclude from picker
const usedRepositoryPaths = $derived(
gitSyncContext.repositories
.map((r, i) => (i !== idx ? r.git_repo_resource_path : null))
.filter((path): path is string => Boolean(path?.trim()))
)
// Determine display title based on variant and legacy status
const displayTitle = $derived(
variant === 'primary-sync'
? mode === 'sync'
? 'Git Sync'
: 'Git Promotion'
: variant === 'primary-promotion'
? 'Git Promotion'
: isLegacy
? 'Legacy promotion repository'
: isSecondary
? repo?.use_individual_branch
? 'Secondary promotion repository'
: 'Secondary sync repository'
: `Repository #${(idx ?? 0) + 1}`
)
// Determine the actual mode based on repository configuration
const repoMode = $derived<'sync' | 'promotion'>(
variant === 'primary-promotion' || variant === 'legacy' || repo?.use_individual_branch
? 'promotion'
: 'sync'
)
// Determine display description based on variant and mode
const targetOrDefaultBranch = $derived(targetBranch ? `'${targetBranch}'` : "repo's default")
const displayDescription = $derived(
variant === 'primary-sync' || variant === 'primary-promotion'
? mode === 'sync'
? `Deploys are committed to the ${targetOrDefaultBranch} branch, and new commits to it can deploy back into this workspace automatically`
: mode === 'promotion'
? `Each deploy in this workspace pushes its changes to a dedicated wm_deploy/** branch of the repository instead of committing to ${targetOrDefaultBranch} directly. Merging that branch into ${targetOrDefaultBranch} promotes the change: the workspace that syncs ${targetOrDefaultBranch} deploys it on merge, so set up Git Sync there. Windmill can open the pull request for each deploy branch (toggle below), or use the open-pr-on-commit workflow.`
: null
: null
)
const shouldShowEmptyState = $derived(
showEmptyState || (!repo && (variant === 'primary-sync' || variant === 'primary-promotion'))
)
async function handleSave() {
if (!repo || idx === null) return
try {
await gitSyncContext.saveRepository(idx)
sendUserToast('Repository settings updated')
} catch (error: any) {
console.error('Failed to save repository:', error)
sendUserToast('Failed to save repository: ' + apiErrorMessage(error), true)
}
}
function handleRevert() {
if (!repo || idx === null) return
try {
gitSyncContext.revertRepository?.(idx)
sendUserToast('Reverted repository settings')
} catch (error: any) {
console.error('Failed to revert repository:', error)
sendUserToast('Failed to revert repository: ' + error.message, true)
}
}
function initiateDelete() {
confirmingDelete = true
}
async function confirmDelete() {
if (idx === null) return
try {
await gitSyncContext.removeRepository(idx)
sendUserToast('Repository connection removed successfully')
} catch (error: any) {
console.error('Failed to remove repository:', error)
sendUserToast('Failed to remove repository: ' + apiErrorMessage(error), true)
} finally {
confirmingDelete = false
}
}
function cancelDelete() {
confirmingDelete = false
}
function runGitSyncTestJob() {
if (idx !== null && gitSyncContext.runTestJob) {
gitSyncContext.runTestJob(idx)
}
}
function emptyString(str: string | undefined | null): boolean {
return !str || str.trim() === ''
}
function handlePullSettings() {
if (idx !== null) {
gitSyncContext.showPullModal(idx, true)
}
}
</script>
<!-- Shared snippets for reusable content -->
{#snippet headerActions()}
{#if !isLegacy}
{#if validation?.hasChanges && validation?.isValid && !repo.isUnsavedConnection}
<Button size="xs" variant="accent" onclick={handleSave} startIcon={{ icon: Save }}>
{repo.legacyImported ? 'Migrate and save' : 'Save changes'}
</Button>
{#if idx !== null && gitSyncContext.initialRepositories[idx] && !repo.legacyImported}
<Button color="light" size="xs" onclick={handleRevert} startIcon={{ icon: RotateCcw }}>
Revert
</Button>
{/if}
{/if}
{/if}
{#if isCollapsible}
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (repo.collapsed = !repo.collapsed)}
aria-label="Toggle collapse"
>
{#if repo.collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
{/if}
</button>
{/if}
{#if !confirmingDelete}
<div transition:fade|local={{ duration: 100 }}>
<Button
size="xs"
variant="default"
onclick={initiateDelete}
startIcon={{ icon: Trash }}
destructive
>
Delete
</Button>
</div>
{:else}
<div class="flex gap-1">
<button
transition:fade|local={{ duration: 100 }}
class="px-3 py-1 text-xs bg-red-500 text-white rounded duration-200 hover:bg-red-600"
onclick={confirmDelete}
>
Confirm delete
</button>
<button
transition:fade|local={{ duration: 100 }}
class="px-2 py-1 text-xs bg-surface-secondary rounded duration-200 hover:bg-surface-hover"
onclick={cancelDelete}
>
<XCircle size={12} />
</button>
</div>
{/if}
{/snippet}
{#snippet repositoryContent()}
<div class="space-y-4">
<!-- Resource Picker -->
<div class="flex gap-2 items-center">
<div class="font-semibold text-xs text-emphasis">Resource:</div>
<div class="flex-1">
<ResourcePicker
bind:value={repo.git_repo_resource_path}
resourceType={'git_repository'}
disabled={!repo.isUnsavedConnection}
excludedValues={usedRepositoryPaths}
/>
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.git_repo_resource_path)}
variant="default"
onclick={runGitSyncTestJob}
size="xs"
>
Test connection
</Button>
{/if}
</div>
<!-- Display resource info when disabled (saved connection) -->
{#if !repo.isUnsavedConnection && repo.git_repo_resource_path}
<div class="text-xs">
{#if loadingResourceInfo}
<div class="flex items-center gap-1 text-secondary">
<RotateCw size={12} class="animate-spin" />
<span>Loading resource info...</span>
</div>
{:else if resourceInfo?.url}
<div class="flex items-center gap-2 text-secondary">
<span class="text-xs text-secondary">Git URL:</span>
<code class="bg-surface-secondary px-2 py-1 rounded text-primary"
>{resourceInfo.url}</code
>
</div>
{:else if resourceInfo?.error}
<div class="text-red-600">{resourceInfo.error}</div>
{/if}
</div>
{/if}
{#if !emptyString(repo.git_repo_resource_path)}
<!-- Validation and Test Status -->
{#if validation?.isDuplicate}
<div class="text-red-600 text-sm">
This resource is already used by another repository.
</div>
{/if}
{#if gitSyncTestJob && gitSyncTestJob.status !== undefined}
<div class="flex text-sm gap-1 items-center">
{#if gitSyncTestJob.status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJob.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Git sync resource checked via Windmill job
<a
target="_blank"
href={`/run/${gitSyncTestJob.jobId}?workspace=${$workspaceStore}`}
class="text-blue-500 hover:underline"
>
{gitSyncTestJob.jobId}
</a>
<span class="text-secondary">WARNING: Only read permissions are verified.</span>
</div>
{#if gitSyncTestJob.status === 'failure' && gitSyncTestJob.error}
<div class="text-red-600 text-xs mt-1">
Error: {gitSyncTestJob.error}
</div>
{/if}
{/if}
<!-- Warnings -->
{#if repo.legacyImported}
<Alert type="warning" title="Legacy git sync settings imported">
This repository was initialized from workspace-level legacy Git-Sync settings. Review the
filters and press <b>Save</b> to migrate.
</Alert>
{/if}
{#if repo.script_path}
<Alert type="warning" title="Pinned git sync script version">
This repository uses a pinned sync script: <code>{repo.script_path}</code>. Switch to
auto-managed to always use the latest version bundled with Windmill.
<div class="flex mt-2">
<Button
size="xs"
variant="accent"
onclick={() => {
if (repo) {
repo.script_path = undefined
}
}}
>
Switch to auto-managed (requires save)
</Button>
</div>
</Alert>
{/if}
<!-- Configuration -->
{#if repo.isUnsavedConnection && !emptyString(repo.git_repo_resource_path) && idx !== null}
<DetectionFlow {idx} mode={repoMode} />
{:else}
<GitSyncFilterSettings
bind:git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={repo.legacyImported}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={false}
requiresMigration={repo.legacyImported}
useIndividualBranch={repo.use_individual_branch}
>
{#snippet actions()}
<Button size="md" onclick={handlePullSettings} startIcon={{ icon: Download }}>
Pull settings
</Button>
{/snippet}
</GitSyncFilterSettings>
{#if !repo.isUnsavedConnection}
{#if !emptyString(repo.git_repo_resource_path) && !repo.legacyImported}
<!-- Direction 1: Windmill -> Git (commit/push on deploy) -->
<div class="mt-4 border-t border-gray-200 pt-3 dark:border-gray-700">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<div class="text-sm font-semibold text-emphasis mb-1">
Push to Git on deploy (Windmill → Git)
</div>
{#if !isFork || (isDevWorkspace && repoMode === 'promotion')}
<GitSyncModeDisplay mode={repoMode} {targetBranch} repository={repo} active />
{/if}
</div>
<Button
size="xs"
variant="default"
onclick={() => idx !== null && gitSyncContext.showPushModal(idx)}
startIcon={{ icon: Upload }}
>
Push to repo
</Button>
</div>
{#if devPromotion && !repo.isUnsavedConnection}
<div class="mt-2">
<Toggle
checked={repo.use_individual_branch ?? false}
disabled={savingDevPromotion}
options={{
right: 'Promote to prod via Git',
rightTooltip:
"Each deploy pushes a per-item wm_deploy/** branch to prod's repository, ready to open a pull request into its tracked branch, instead of committing to the dev branch. Enable automatic pull requests below to have Windmill open them. Reuses prod's repository, no separate setup."
}}
on:change={(e) => setDevPromotion(e.detail)}
/>
{#if repo.use_individual_branch}
<div class="mt-2">
<Toggle
checked={repo.group_by_folder ?? false}
disabled={savingDevPromotion}
options={{
right: 'One pull request per folder',
rightTooltip:
"Group a folder's items into a single wm_deploy/** branch, ready as one pull request, instead of one branch per item."
}}
on:change={(e) => setGroupByFolder(e.detail)}
/>
</div>
{/if}
</div>
{/if}
{#if repoMode === 'promotion' && isGithubApp}
<div class="mt-2">
<!-- Locked while the dev promotion toggle's save is in flight: this
toggle is revealed by that save, and an edit made mid-save would be
absorbed into the saved baseline without ever reaching the backend. -->
<Toggle
checked={repo.promotion_open_prs ?? false}
disabled={savingDevPromotion}
options={{
right: 'Open a pull request for each deploy branch',
rightTooltip:
'After a deploy pushes its wm_deploy/** branch, Windmill opens a pull request to the target branch. Runs from the deploy itself, so it works without inbound webhooks.'
}}
on:change={(e) => setPromotionOpenPrs(e.detail)}
/>
</div>
{:else if repoMode === 'promotion' && !repo.isUnsavedConnection}
<div class="text-2xs text-secondary mt-2">
To open a pull request for each deploy branch, set up the
<a
href="https://www.windmill.dev/docs/advanced/deploy_gh_gl#github-actions-setup"
target="_blank"
class="text-blue-500 hover:underline font-mono">open-pr-on-commit</a
>
workflow in the repository. Recommended: connect the repository through the
<a
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
target="_blank"
class="text-blue-500 hover:underline">GitHub App</a
> and Windmill opens them automatically.
</div>
{/if}
{#if repoMode === 'sync' && isFork}
<div class="text-2xs text-secondary mt-2">
Deploys from this workspace are pushed to the
<span class="font-mono">{forkBranch}</span> branch of the shared repository, not to
the tracked branch.
</div>
{:else if repoMode === 'sync' && !isFork}
<div class="text-2xs text-secondary mt-2">
These push settings also apply to forks of this workspace: an item deployed in a
fork is pushed to the fork's own
<span class="font-mono">wm-fork/…</span> branch instead of the tracked branch.
</div>
{#if isGithubApp}
<div class="mt-2">
<Toggle
checked={repo.fork_open_prs ?? false}
disabled={!$enterpriseLicense}
options={{
right: 'Open a pull request when an item is deployed in a fork',
rightTooltip:
"After an item deployed in a fork is pushed to the fork's branch (wm-fork/**, or the dev branch for a dev workspace), Windmill opens a pull request to the tracked branch of the shared repository. Runs from the deploy itself, so it works without inbound webhooks. When a dev workspace enables Git promotion, its own pull request toggle takes over for its wm_deploy/** branches."
}}
on:change={(e) => setForkOpenPrs(e.detail)}
>
{#snippet right()}
{#if !$enterpriseLicense}<EEOnly />{/if}
{/snippet}
</Toggle>
</div>
{:else}
<div class="text-2xs text-secondary mt-2">
To open pull requests when an item is deployed in a fork, set up the
<a
href="https://www.windmill.dev/docs/advanced/git_sync#github-actions"
target="_blank"
class="text-blue-500 hover:underline font-mono">open-pr-on-fork-commit</a
>
workflow in the repository. Recommended: connect the repository through the
<a
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
target="_blank"
class="text-blue-500 hover:underline">GitHub App</a
> and Windmill opens them automatically.
</div>
{/if}
{/if}
{#if repo.open_pr_error}
<div class="mt-2">
<Alert type="warning" title="Last pull request couldn't be opened" size="xs">
{repo.open_pr_error} If this mentions permissions, the GitHub App installation may
not have approved the pull-request permission yet.
</Alert>
</div>
{/if}
</div>
<!-- Direction 2: Git -> Windmill (pull / auto-deploy). Promotion repos
push deploy branches on top of a sync-mode setup; the pull
direction belongs to that sync repo, so it's hidden here. -->
{#if repoMode === 'sync'}
<div class="mt-4 border-t border-gray-200 pt-3 dark:border-gray-700">
<div class="flex justify-between items-start gap-4">
<div class="text-sm font-semibold text-emphasis"
>Pull from Git (Git → Windmill)</div
>
<Button
size="xs"
variant="default"
onclick={() => idx !== null && gitSyncContext.showPullModal(idx)}
startIcon={{ icon: Download }}
>
Pull from repo
</Button>
</div>
{#if isFork}
<!-- Fork sync is parent-managed: no control here, only status. -->
<div class="text-2xs text-secondary mt-2">
Automatic sync from Git is managed in the parent workspace's git sync settings.
When enabled there, changes to this fork's
<span class="font-mono">{forkBranch}</span> branch deploy here automatically.
</div>
{#if repo.auto_pull?.last_pull_status}
<div class="text-2xs text-secondary mt-2">
{#if repo.auto_pull.last_pull_status.success}
Last synced{repo.auto_pull.last_pull_status.synced_sha
? ` to ${repo.auto_pull.last_pull_status.synced_sha.slice(0, 7)}`
: ''}.
{:else}
<span class="text-red-600 dark:text-red-400">
Last sync failed{repo.auto_pull.last_pull_status.error
? `: ${repo.auto_pull.last_pull_status.error}`
: ''}.
</span>
{/if}
</div>
{/if}
{:else}
<Toggle
checked={repo.auto_pull?.enabled ?? false}
disabled={!$enterpriseLicense}
options={{
right: 'Automatically deploy changes from Git',
rightTooltip:
'Windmill deploys new commits from the tracked branch into this workspace. Repositories connected through the GitHub App sync instantly via webhooks with a polling fallback; token-based repositories are checked about every minute.'
}}
on:change={(e) => setAutoPullEnabled(e.detail)}
>
{#snippet right()}
{#if !$enterpriseLicense}<EEOnly />{/if}
{/snippet}
</Toggle>
<div class="mt-1">
<Toggle
disabled={!repo.auto_pull?.enabled}
checked={!!(repo.auto_pull?.enabled && repo.auto_pull?.sync_forks)}
options={{
right: 'Automatically sync forks with git branches',
rightTooltip: repo.auto_pull?.enabled
? "When a fork's wm-fork/** branch changes in the repository (for example after merging the tracked branch into it), Windmill deploys those commits into the fork workspace. Configured once here, applied to every fork of this workspace."
: 'Requires automatic deploy from Git to be enabled above.'
}}
on:change={(e) => setSyncForks(e.detail)}
/>
</div>
{/if}
{#if !isGithubApp && !loadingResourceInfo}
<div class="mt-2">
<Alert type="info" title="Instant pull recommended" size="xs">
Pull for this repository checks the tracked branch about every minute; longer
gaps make drift and merge conflicts more likely. For instant pull, connect the
repository through the
<a
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
target="_blank"
class="text-blue-500 hover:underline">GitHub App</a
>
(which also lets Windmill manage pull requests), or push changes into Windmill
with the
<a
href="https://www.windmill.dev/docs/advanced/git_sync#github-actions"
target="_blank"
class="text-blue-500 hover:underline">sync GitHub workflow</a
>. If you already push changes with a GitHub Action, keep either the Action or
automatic pull, not both, so they don't fight over deploys.
</Alert>
</div>
{/if}
{#if repo.auto_pull?.enabled}
{@const viaWebhook = repo.auto_pull?.webhook_id != null}
{#if isGithubApp}
<div class="mt-2">
<Alert type="info" title="Already pulling with a GitHub Action?" size="xs">
If you previously set up a GitHub Action to push changes into Windmill,
remove it now so the two don't fight over deploys.
</Alert>
</div>
{/if}
<div class="text-2xs text-secondary mt-2">
{#if repo.auto_pull?.last_pull_status}
{#if repo.auto_pull.last_pull_status.success}
Last synced{repo.auto_pull.last_pull_status.synced_sha
? ` to ${repo.auto_pull.last_pull_status.synced_sha.slice(0, 7)}`
: ''}.
{viaWebhook
? ' Syncing instantly via webhook.'
: ' Checking the tracked branch about every minute.'}
{:else}
<span class="text-red-600 dark:text-red-400">
Last sync failed{repo.auto_pull.last_pull_status.error
? `: ${repo.auto_pull.last_pull_status.error}`
: ''}.
</span>
{/if}
{:else}
{viaWebhook
? 'Connected via webhook. New commits to the tracked branch deploy instantly.'
: 'Checking the tracked branch about every minute. New commits deploy automatically.'}
{/if}
</div>
{#if isGithubApp && repo.auto_pull?.webhook_error}
<div class="mt-2">
<Alert type="warning" title="Falling back to polling" size="xs">
{repo.auto_pull.webhook_error}
</Alert>
</div>
{/if}
{/if}
</div>
{/if}
{/if}
{/if}
{/if}
{:else}
<div class="text-xs text-primary">Please select a Git repository resource.</div>
{/if}
</div>
{/snippet}
<!-- Main component rendering -->
{#if shouldShowEmptyState}
<!-- Empty State for Primary Variants -->
<div class="rounded-md shadow-sm bg-surface-tertiary p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xs font-semibold text-emphasis">{displayTitle}</h3>
{#if displayDescription}
<p class="text-2xs text-secondary"
>{displayDescription}
{#if mode === 'promotion'}
<a target="_blank" href="https://www.windmill.dev/docs/advanced/deploy_gh_gl"
>Learn more about Git Promotion</a
>
{/if}
</p>
{/if}
</div>
</div>
<div
class="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed rounded-md border-border-normal"
>
<div class="text-center mb-4">
<p class="text-primary text-xs font-normal mb-2">
{#if mode === 'sync'}
No Git Sync repository configured. Add one to enable direct synchronization.
{:else if mode === 'promotion'}
No Git Promotion repository configured. Add one to enable branch-based workflows.
{:else}
No repository configured.
{/if}
</p>
</div>
{#if onAdd}
<Button unifiedSize="md" variant="default" startIcon={{ icon: Plus }} onclick={onAdd}>
Add {mode || 'repository'} repository
</Button>
{/if}
</div>
</div>
{:else if repo}
{#if variant === 'primary-sync' || variant === 'primary-promotion'}
<!-- Primary Repository Layout -->
<div class="rounded-md shadow-sm bg-surface-tertiary p-4 mb-4">
<div class="flex flex-col mb-4 gap-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">{displayTitle}</h3>
<div class="flex items-center gap-2">
{@render headerActions()}
</div>
</div>
{#if displayDescription}
<p class="text-xs text-secondary"
>{displayDescription}
{#if mode === 'promotion'}
<a target="_blank" href="https://www.windmill.dev/docs/advanced/deploy_gh_gl"
>Learn more about Git Promotion</a
>
{/if}</p
>
{/if}
</div>
{@render repositoryContent()}
</div>
{:else}
<!-- Standard Repository Card Layout -->
<div class="rounded-md shadow-sm bg-surface-tertiary p-0 w-full mb-4">
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{displayTitle}</span>
{#if repo.legacyImported}
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-200"
>
Legacy Configuration
</span>
{/if}
<span class="text-xs text-primary pt-1 pl-8">
{repo.git_repo_resource_path}
</span>
</div>
<div class="flex items-center gap-2">
{@render headerActions()}
</div>
</div>
{#if !repo.collapsed}
<div class="px-4 py-2">
{@render repositoryContent()}
</div>
{/if}
</div>
{/if}
{/if}