Files
windmill/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte
T
2ce21c9ef8 feat(git-sync): enable per-item promotion mode on dev workspaces (#10205)
* feat(git-sync): enable per-item promotion mode on dev workspaces

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

* style: keep unrelated git-sync Alert copy at its original wrapping

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

* fix(git-sync): fall back to parent_path on empty deploy path + bump ee ref

computeGitSyncDeployBranch used ?? so a backend-serialized empty path (rename out of the repo filter) skipped the deploy branch and could commit to the tracked base; use || to fall back to parent_path like the backend. Bumps ee-repo-ref for the single-object promotion_open_prs fix (windmill-ee-private#679).

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

* fix(git-sync): route dev-promotion non-branchable objects off the tracked base

user/group objects (and any unresolvable ref) returned null in promotion mode, so a dev-workspace deploy pushed them straight to the parent's tracked branch. Fall back to the dev's env-label branch instead; the backend opens no PR for them (isolated, not promoted).

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

* feat(git-sync): dev-workspace promotion via a toggle on the inherited repo

A dev workspace reuses the single repo it inherited from prod: a 'Promote to prod via Git' toggle flips it between sync mode (deploys to the dev branch) and promotion mode (per-item wm_deploy/** PRs to prod), with a per-item/per-folder sub-toggle. Removes the redundant separate-promotion-repo setup for dev workspaces.

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

* test(git-sync): dev-promotion regression test + widen git_sync_e2e path filter

Adds a CLI integration case covering dev-workspace promotion (script -> wm_deploy branch; user/group -> env-label branch, main never touched). Widens the git-sync-test.yml relevance filter to the deploy-branch derivation, git-sync guard, and CLI git-deploy files so the e2e suite runs on PRs like this one.

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

* fix(git-sync): gate dev promotion toggle on EE, fix card mode + workflow path filters

Codex review: (1) show the dev promotion toggle only under an active EE license and revert the optimistic save if the backend rejects it; (2) derive the dev card's display mode from use_individual_branch so promotion copy shows in promotion mode; (3) mirror the new relevance paths into the workflow's top-level push/pull_request filters so it actually triggers.

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

* fix(git-sync): only use the single-card dev promotion UX when the dev has one repo

Codex review: an attached dev workspace keeps its own repositories rather than inheriting prod's. Gating the single-card + toggle + hidden-secondaries UX on repositories.length <= 1 makes a multi-repo attached dev fall back to the normal layout, so no active repo is hidden and an unrelated repo isn't presented as prod's promotion target.

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

* fix(git-sync): runtime EE-plan gate for promotion mode, consistent with auto-pull/PR

Codex review: promotion mode only had the CE compile rejection, while auto-pull and PR creation runtime-gate on the active plan (check_git_sync_ee_license). Add check_promotion_license and call it from both edit_git_sync_config and edit_git_sync_repository, plus the matching CE rejection on edit_git_sync_config so the two endpoints are symmetric. Promotion is now gated like every other git-sync EE setting.

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

* fix(git-sync): dev promotion must reuse the parent workspace's repository

Codex review: repository count doesn't prove a dev inherited prod's repo — an attached dev keeps its own. check_dev_promotion_targets_parent_repo resolves the promotion repo's URL and rejects enabling promotion unless it matches one the parent (prod) tracks, so branches/PRs can't target an unrelated repository. Called from both git-sync edit endpoints.

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

* fix(git-sync): dev promotion save-time check uses shared parent-repo matcher (url+branch)

Delegates to windmill_common::git_sync_ee::dev_promotion_target_matches_parent so the settings gate and the deploy-time safety net share one url+branch identity check. Bumps ee-repo-ref for the EE deploy-time enforcement.

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

* chore: bump ee-repo-ref for private resolve_repo_url_and_branch

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

* chore: bump ee-repo-ref for promotion-target matcher authz doc

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

* chore(git-sync): bump hub scripts to gitsync-cli versions, fix promotion tooltips

Point LATEST_GIT_SYNC_SCRIPT_PATH (28790 -> 28796) and
GIT_SYNC_PULL_SCRIPT_PATH / gitInitRepo (28789 -> 28795) at the hub
versions pinning windmill-cli@1.763.1-gitsync.0, which carries the
dev-workspace promotion routing. Slugs unchanged, so the GitHub-App
token check and hub script cache are unaffected.

Tooltips: enabling promotion pushes a PR-ready wm_deploy/** branch;
Windmill only opens the pull request itself when automatic pull
requests are enabled. Reword both toggles to stop promising a PR.

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

* fix(git-sync): dev promotion mirrors to the env-label branch, PR toggles exclusive by branch type

Bump ee-repo-ref for the dispatcher changes: a promotion dev's deploys
now also push to its env-label branch (one extra mirror job per batch,
users/groups mirror-only), and `fork_open_prs` no longer applies to a
dev in promotion mode where `promotion_open_prs` governs.

Frontend: the fork-PR toggle tooltip states its actual coverage
(wm-fork/** and the dev branch of a dev workspace) and that a promotion
dev's own pull request toggle takes over for wm_deploy/** branches.

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

* fix(git-sync): reject dev promotion on pre-28796 pinned sync scripts

An older pinned sync script bundles a CLI that force-disables per-item
branches on every fork, so enabling promotion on a dev workspace with
such a pin would silently keep deploying to the env-label branch. Both
git-sync edit endpoints now reject the combination with an actionable
error; the EE dispatchers (via ee-repo-ref bump) demote inherited
configs to promotion-off semantics so markers, branch keys and the
mirror match the branch the CLI actually pushes. Roots and auto-managed
repositories are unaffected.

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

* fix(git-sync): serialize dev promotion toggle saves

The promotion and per-folder toggles persist immediately via whole-repo
saves; leaving them interactive while one is pending lets rapid flips
race, and the earlier save (enabling runs extra backend checks) can
commit last, silently reversing the state the UI shows. Both toggles now
disable while a save is in flight.

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

* fix(git-sync): lock auto-PR toggle during promotion save, rename-out branch routing

Frontend: the automatic-PR toggle is revealed by the promotion toggle's
in-flight save; an edit made mid-save was absorbed into the saved
baseline without reaching the backend. It now disables during that save.

EE (ee-repo-ref bump): dispatcher debounce/concurrency keys and PR
markers follow the CLI's parent_path fallback for rename-out items, so
their wm_deploy/** branches debounce per-branch and open their PR.

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

* chore(git-sync): condense comments to durable constraints

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

* chore: update ee-repo-ref to 8bf73f803158bcbf7b8d55a36f4a1ebfcc1bbcd9

This commit updates the EE repository reference after PR #679 was merged in windmill-ee-private.

Previous ee-repo-ref: c2cd718cb53d234f909f485bd7cd43ed9605ffd1

New ee-repo-ref: 8bf73f803158bcbf7b8d55a36f4a1ebfcc1bbcd9

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-21 17:07:52 +00:00

996 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 { 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: ${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: ${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: ' + error.message, 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: ' + error.message, 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}