Files
windmill/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts
T
Diego ImbertandClaude Opus 5 504d01a563 feat: rework the git sync setup into a guided flow (#11308)
* feat: rework git sync settings into a flat repo list and setup modal

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

* feat: add the connect step for gitlab and github in git sync setup

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

* feat: open the github app install page from the git sync setup next step

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

* feat: let git sync setup set the repository folder

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

* feat: add the configure sync step to git sync setup

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

* style: even out the git sync filter settings columns

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

* style: pin the filter input to the bottom of the filter list

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

* style: frame the git sync path filters in a box with an empty hint

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

* refactor: use a multiselect for the git sync path filters

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

* feat: grey out the skipped connect step in git sync setup

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

* style: move the check job line to the bottom of the setup modal

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

* fix: hide the check job line once the repository check resolved

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

* style: center the repository check loader in the setup modal

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

* fix: stop the setup modal shifting when the check job id arrives

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

* style: move the existing resource hint to the setup modal footer

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

* feat: always show the existing resource option when one exists

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

* style: drop the card and collapse from the git sync filter settings

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

* style: use a success alert for the pull from git notice

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

* feat: make initializing the repository a step of the git sync setup

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

* style: hide the push job lines once they succeed

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

* style: reword the push step intro

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

* style: copy button on the cli snippet and a taller changes list

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

* style: match the push step loading to the check step

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

* fix: stop the push step shifting when a job id arrives

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

* style: let the changes list fill the push step

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

* feat: end the git sync setup on a done step instead of a modal

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

* feat: delete a git sync repository from a row menu with a confirmation

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

* style: lift the git sync done message off the notices

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

* feat: restore github app installation transfer between instances

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

* fix: address regressions from the git sync setup rework

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

* fix: keep the overlay z-index floor in step with the ai chat

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

* fix: hold the push state in the setup dialog, not in the step it destroys

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

* fix: block every dismissal while setup work is in flight and keep sync reachable

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

* fix: regenerate the resource path when the repository changes

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

* fix: configure the resource the connect step actually created

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

* fix: keep a modal open when a drawer above it takes the click

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

* fix: snapshot the topmost surface for keyboard clicks too

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

* style: size the add repository buttons and explain git promotion

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

* revert: drop the promotion-first action from the empty state

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

* style: shrink the test connection result line

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

* feat: offer the fork sync and fork pull request toggles during setup

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

* style: group promotion repositories under their own label

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

* feat: offer the deploy branch pull request toggle during setup

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

* fix: wait for the repository credential before saving the setup

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

* fix: ignore a superseded credential load and drop the bindable default

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

* fix: key setup readiness to the repository it was loaded for

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

* fix: tie setup readiness to the attempt that asked for it

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

* feat: link to where the access token is created

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

* style: say plainly how each provider connects

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

* feat: sync resources, variables, schedules and triggers by default

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

* style: use the accent colour for the token help link

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

* style: move the CI/CD deploy hint next to the pull toggle

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

* fix: keep setup gated when the connection could not be read

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

* fix: only explain pull-from-git where the workspace can turn it on

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

* fix: offer a save retry once the repository is initialized

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

* fix: say when pulling fell back to polling on the last setup step

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

* fix: clear the initialized flag when a new draft starts

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 11:54:25 +02:00

951 lines
28 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type {
GitRepositorySettings as BackendGitRepositorySettings,
GitSyncObjectType
} from '$lib/gen'
import { jobManager } from '$lib/services/JobManager'
import hubPaths from '$lib/hubPaths.json'
import type { SettingsObject } from '$lib/git-sync'
export type GitSyncRepository = BackendGitRepositorySettings & {
settings: SettingsObject
exclude_types_override: GitSyncObjectType[]
legacyImported?: boolean
isUnsavedConnection?: boolean
collapsed?: boolean
// Repository detection state for new connections
detectionState?: 'idle' | 'loading' | 'no-wmill' | 'has-wmill' | 'error'
extractedSettings?: SettingsObject
detectionError?: string
// Job tracking for detection
detectionJobId?: string
detectionJobStatus?: 'running' | 'success' | 'failure'
// Internal tracking for resource path changes
_trackedPath?: string
// Cached target branch from git resource
_targetBranch?: string
// Detection timestamp to avoid race conditions
_detectionTimestamp?: number
}
export type GitSyncTestJob = {
jobId: string
status: 'running' | 'success' | 'failure' | undefined
error?: string
}
export type GitSyncSettings = {
repositories: GitSyncRepository[]
}
export type ModalState = {
push: { idx: number; repo: GitSyncRepository; open: boolean } | null
pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null
success: {
open: boolean
savedWithoutInit?: boolean
autoPullOn?: boolean
/** Why pulling fell back to polling, when it did. */
webhookError?: string
/** Whether this workspace is the one that would turn pulling on for this repository. */
ownsAutoPull?: boolean
} | null
}
export type ValidationState = {
isValid: boolean
isDuplicate: boolean
hasChanges: boolean
}
const GIT_SYNC_CONTEXT_KEY = Symbol('git-sync-context')
// Context implementation
export function createGitSyncContext(workspace: string) {
const repositories = $state<GitSyncRepository[]>([])
const initialRepositories = $state<GitSyncRepository[]>([])
const gitSyncTestJobs = $state<GitSyncTestJob[]>([])
let loading = $state(false)
const activeModals = $state<ModalState>({ push: null, pull: null, success: null })
// Legacy workspace-level settings state
const legacyWorkspaceIncludePath = $state<string[]>([])
const legacyWorkspaceIncludeType = $state<GitSyncObjectType[]>([])
// Derived state for legacy detection
const hasWorkspaceLevelSettings = $derived(
legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0
)
// Watch for changes to git repository paths and reset detection state
$effect(() => {
repositories.forEach((repo) => {
if (repo.isUnsavedConnection) {
const currentPath = repo.git_repo_resource_path
if (repo._trackedPath && repo._trackedPath !== currentPath) {
// Clear cached branch when resource path changes
repo._targetBranch = undefined
// Reset detection state for any non-idle state (including errors)
if (repo.detectionState && repo.detectionState !== 'idle') {
// Cancel any running detection job by resetting immediately
_resetRepoDetectionState(repo)
}
}
repo._trackedPath = currentPath
}
})
})
const getValidationStates = () => {
return repositories.map((repo, idx) => ({
isValid: validateRepository(repo, idx),
isDuplicate: checkDuplicate(repo, idx),
hasChanges: checkChanges(repo, idx)
}))
}
const getHasAnyChanges = () => {
const validationStates = getValidationStates()
// Check if any individual repositories have changes
const individualChanges = validationStates.some((v) => v.hasChanges)
// Check if any legacy repos were imported
const anyLegacyImported = repositories.some((r) => r.legacyImported)
// Check if the set of repositories has changed (added/removed repos)
const repositorySetChanged = (() => {
if (loading) {
return false
}
if (!initialRepositories || initialRepositories.length === 0) {
return repositories.filter((_, i) => validationStates[i]?.isValid).length > 0
}
const initialValidPaths = new Set(
initialRepositories
.filter((r) => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '')
.map((r) => r.git_repo_resource_path)
)
const currentValidPaths = new Set(
repositories
.filter((_, i) => validationStates[i]?.isValid)
.map((r) => r.git_repo_resource_path)
)
// Check if sets are different (repos added or removed)
return (
initialValidPaths.size !== currentValidPaths.size ||
[...initialValidPaths].some((path) => !currentValidPaths.has(path)) ||
[...currentValidPaths].some((path) => !initialValidPaths.has(path))
)
})()
return individualChanges || anyLegacyImported || repositorySetChanged
}
const getAllRepositoriesValid = () => getValidationStates().every((v) => v.isValid)
const getHasUnsavedConnections = () => repositories.some((repo) => repo.isUnsavedConnection)
function validateRepository(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
return !checkDuplicate(repo, idx)
}
function checkDuplicate(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
const firstIdx = repositories.findIndex(
(r) => r.git_repo_resource_path === repo.git_repo_resource_path
)
return firstIdx !== -1 && firstIdx < idx
}
function checkChanges(repo: GitSyncRepository, idx: number): boolean {
const initial = initialRepositories[idx]
if (!initial) return true
// Legacy repositories always have "changes" because they need migration
if (repo.legacyImported) return true
return (
JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial))
)
}
function serializeRepository(repo: GitSyncRepository) {
return {
git_repo_resource_path: repo.git_repo_resource_path,
script_path: repo.script_path,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder,
settings: repo.settings,
exclude_types_override: repo.exclude_types_override,
auto_pull: repo.auto_pull,
promotion_open_prs: repo.promotion_open_prs,
fork_open_prs: repo.fork_open_prs
}
}
function addRepository() {
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
use_individual_branch: false,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: [
'script',
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false,
// New connections default to pulling changes from Git (webhook with a
// polling fallback), forks included. Existing repos load without
// auto_pull and stay off until an admin opts in, so upgrades never
// start auto-deploying.
auto_pull: { enabled: true, mode: 'auto', sync_forks: true },
fork_open_prs: true
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
async function removeRepository(idx: number) {
const repo = repositories[idx]
if (!repo) return
// Check if this repository exists in the initial (saved) state
const existsInInitialState = initialRepositories.some(
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
// Only call backend API if repository exists in the saved state
if (existsInInitialState) {
await WorkspaceService.deleteGitSyncRepository({
workspace,
requestBody: {
git_repo_resource_path: `$res:${repo.git_repo_resource_path}`
}
})
// Update initial state to remove the deleted repository
const initialIdx = initialRepositories.findIndex(
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
if (initialIdx !== -1) {
initialRepositories.splice(initialIdx, 1)
}
}
// Remove from local state
repositories.splice(idx, 1)
gitSyncTestJobs.splice(idx, 1)
}
function getRepository(idx: number) {
return repositories[idx]
}
function showPushModal(idx: number) {
const repo = repositories[idx]
if (repo) {
activeModals.push = { idx, repo, open: true }
}
}
function showPullModal(idx: number, settingsOnly = false) {
const repo = repositories[idx]
if (repo) {
activeModals.pull = { idx, repo, open: true, settingsOnly }
}
}
function closeModal(type: 'push' | 'pull' | 'success') {
if (activeModals[type]) {
activeModals[type]!.open = false
}
setTimeout(() => {
activeModals[type] = null
}, 200)
}
function closePushModal() {
closeModal('push')
}
function closePullModal() {
closeModal('pull')
}
/** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion
* repositories have none, and a fork's pulling is its parent's to set. */
function ownsAutoPull(repo: GitSyncRepository): boolean {
const ws = get(workspaceStore)
const isFork =
(ws?.startsWith('wm-fork-') ?? false) ||
!!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id
return !repo.use_individual_branch && !isFork
}
function showSuccessModal(
savedWithoutInit?: boolean,
autoPullOn?: boolean,
ownsAutoPull?: boolean,
webhookError?: string
) {
activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError }
}
function closeSuccessModal() {
closeModal('success')
}
function getValidation(idx: number): ValidationState {
const states = getValidationStates()
return states[idx] || { isValid: false, isDuplicate: false, hasChanges: false }
}
async function detectRepository(idx: number) {
const repo = repositories[idx]
if (!repo || !repo.git_repo_resource_path) {
throw new Error('Repository not found or no resource path')
}
repo.detectionState = 'loading'
repo.detectionError = undefined
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
// Track the detection timestamp to avoid race conditions from old jobs. Stamped before
// the request, or a failure to even start the job reads as superseded and leaves the
// repository loading for good.
const detectionTimestamp = Date.now()
repo._detectionTimestamp = detectionTimestamp
try {
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: repo.git_repo_resource_path,
dry_run: true,
pull: false,
only_wmill_yaml: true,
settings_json: JSON.stringify(repo.settings),
use_promotion_overrides: repo.use_individual_branch
},
skipPreprocessor: true
})
repo.detectionJobId = jobId
repo.detectionJobStatus = 'running'
// Use JobManager for polling - result will be the actual job response
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 60000,
timeoutMessage: 'Detection job timed out after 60s',
onProgress: (status) => {
// Only update state if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
repo.detectionJobStatus = status.status
// Process successful detection result
if (status.status === 'success' && status.result) {
const response = status.result as any
if (response.isInitialSetup) {
repo.detectionState = 'no-wmill'
} else {
repo.detectionState = 'has-wmill'
// Apply extracted settings from the git repository
if (response.local) {
repo.extractedSettings = response.local
// Auto-apply the extracted settings
repo.settings = {
...response.local,
exclude_path: response.local.exclude_path || [],
extra_include_path: response.local.extra_include_path || []
}
}
}
} else if (status.status === 'failure') {
repo.detectionState = 'error'
repo.detectionError = status.error || 'Detection failed'
}
}
})
} catch (error: any) {
// Only set error if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
repo.detectionState = 'error'
repo.detectionError = apiErrorMessage(error) || 'Failed to detect repository'
repo.detectionJobStatus = 'failure'
}
}
// Settings management
async function loadSettings() {
loading = true
try {
const settings = await WorkspaceService.getSettings({ workspace })
if (settings.git_sync !== undefined && settings.git_sync !== null) {
// Detect workspace-level legacy settings (outside repositories)
const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []
const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] =
(settings.git_sync as any)?.include_type ?? []
const workspaceLegacyIncludeType: GitSyncObjectType[] = [...workspaceLegacyIncludeTypeRaw]
// Update legacy workspace state
legacyWorkspaceIncludePath.splice(
0,
legacyWorkspaceIncludePath.length,
...workspaceLegacyIncludePath
)
legacyWorkspaceIncludeType.splice(
0,
legacyWorkspaceIncludeType.length,
...workspaceLegacyIncludeType
)
if (settings.git_sync.repositories) {
repositories.splice(
0,
repositories.length,
...settings.git_sync.repositories.map((repo) => {
// Check if this is a legacy repo (no nested settings object)
const isRepoLegacy = !repo.settings
const repoExcludeTypesOverride = repo.exclude_types_override ?? []
// Determine default types - use workspace legacy or fallback
const defaultTypes: GitSyncObjectType[] =
workspaceLegacyIncludeType.length > 0
? [...workspaceLegacyIncludeType]
: [
'script',
'flow',
'app',
'folder',
'workspacedependencies',
'datatablemigration'
]
let repoSettings: SettingsObject
if (isRepoLegacy) {
// Legacy repo: inherit from workspace-level settings and apply exclude_types_override
const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes]
const effectiveIncludeType =
repoExcludeTypesOverride.length > 0
? inheritedIncludeType.filter(
(type) => !repoExcludeTypesOverride.includes(type)
)
: inheritedIncludeType
repoSettings = {
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: effectiveIncludeType
}
} else {
// New format: use repo's own settings
repoSettings = {
include_path: repo.settings?.include_path ?? ['f/**'],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: repo.settings?.include_type ?? [
'script',
'flow',
'app',
'folder',
'workspacedependencies',
'datatablemigration'
]
}
}
return {
...repo,
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
settings: repoSettings,
exclude_types_override: repoExcludeTypesOverride,
// Mark legacy repos for UI handling
legacyImported: isRepoLegacy
}
})
)
}
}
// Store initial state for change tracking
initialRepositories.splice(
0,
initialRepositories.length,
...repositories.map((repo) => ({ ...repo }))
)
} finally {
loading = false
}
}
// Migration utility for legacy repositories
function migrateLegacyRepository(repo: GitSyncRepository): GitSyncRepository {
if (!repo.legacyImported) {
return repo // Already migrated or not legacy
}
// Create migrated repository - exclude_types_override should already be applied in settings.include_type
// from the loadSettings logic, so we just need to clear the override and mark as migrated
return {
...repo,
exclude_types_override: [], // Clear the override since it's now integrated into include_type
legacyImported: false // Mark as migrated
}
}
/** `announce` off for a caller that reports the save itself — the setup dialog ends on a
* step saying the same thing, and a modal over it would say it twice. */
async function saveRepository(idx: number, savedWithoutInit = false, announce = true) {
const repo = repositories[idx]
if (!repo || !validateRepository(repo, idx)) {
throw new Error('Cannot save invalid repository')
}
// Migrate legacy repository if needed
const repoToSave = repo.legacyImported ? migrateLegacyRepository(repo) : repo
// Use the new individual repository API instead of saving all repositories
await WorkspaceService.editGitSyncRepository({
workspace,
requestBody: {
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
repository: {
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
script_path: repoToSave.script_path,
use_individual_branch: repoToSave.use_individual_branch,
group_by_folder: repoToSave.group_by_folder,
settings: repoToSave.settings,
exclude_types_override: repoToSave.exclude_types_override,
auto_pull: repoToSave.auto_pull,
promotion_open_prs: repoToSave.promotion_open_prs,
fork_open_prs: repoToSave.fork_open_prs
}
}
})
// The server stamps the saving admin as who pulls run as; mirror it so the card
// names them without a reload.
if (repoToSave.auto_pull) {
repoToSave.auto_pull = {
...repoToSave.auto_pull,
enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined,
webhook_error: await readWebhookError(repoToSave)
}
}
// Update local state with migrated repository
repositories[idx] = repoToSave
initialRepositories[idx] = { ...repoToSave }
// Update local state
if (repoToSave.isUnsavedConnection) {
repoToSave.isUnsavedConnection = false
repoToSave.detectionState = undefined
repoToSave.extractedSettings = undefined
// Show success modal for new connections
if (announce) {
showSuccessModal(
savedWithoutInit,
repoToSave.auto_pull?.enabled === true,
ownsAutoPull(repoToSave),
repoToSave.auto_pull?.webhook_error
)
}
}
}
/** The webhook is registered after the settings commit, best-effort, and a failure is
* reported only through the stored settings — so it stays invisible until they are read
* back. Without this, pulling reads as working when it has silently fallen back to
* polling (an app missing the webhook permission, an instance GitHub cannot reach). */
async function readWebhookError(repo: GitSyncRepository): Promise<string | undefined> {
if (!repo.auto_pull?.enabled) return undefined
try {
const settings = await WorkspaceService.getSettings({ workspace })
return settings.git_sync?.repositories?.find(
(r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path
)?.auto_pull?.webhook_error
} catch {
// Only costs the warning; the card shows it on the next load.
return undefined
}
}
// Helper functions for original functionality
function revertRepository(idx: number) {
const initial = initialRepositories[idx]
if (initial) {
repositories[idx] = JSON.parse(JSON.stringify(initial))
}
}
// Reset detection state for a repository
function resetDetectionState(idx: number) {
const repo = repositories[idx]
if (!repo || !repo.isUnsavedConnection) return
_resetRepoDetectionState(repo)
}
// Helper function to reset detection state on a repository object
function _resetRepoDetectionState(repo: GitSyncRepository) {
repo.detectionState = 'idle'
repo.extractedSettings = undefined
repo.detectionError = undefined
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
}
async function runTestJob(idx: number) {
const repo = repositories[idx]
if (!repo?.git_repo_resource_path) {
return
}
try {
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitSyncTest,
requestBody: {
repo_url_resource_path: repo.git_repo_resource_path,
init: repo.isUnsavedConnection || false
},
skipPreprocessor: true
})
gitSyncTestJobs[idx] = {
jobId: jobId,
status: 'running'
}
// Use JobManager for polling
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 10000,
timeoutMessage: 'Git sync test job timed out after 10s',
onProgress: (status) => {
gitSyncTestJobs[idx].status =
status.status === 'success'
? 'success'
: status.status === 'failure'
? 'failure'
: 'running'
if (status.status === 'failure') {
gitSyncTestJobs[idx].error = status.error
}
}
})
} catch (error: any) {
// Initialize the job entry if it doesn't exist (e.g., job creation failed)
const errorMessage = apiErrorMessage(error) || 'Failed to run test job'
if (!gitSyncTestJobs[idx]) {
gitSyncTestJobs[idx] = {
jobId: '',
status: 'failure',
error: errorMessage
}
} else {
gitSyncTestJobs[idx].status = 'failure'
gitSyncTestJobs[idx].error = errorMessage
}
}
}
function getPrimarySyncRepository(): { repo: GitSyncRepository; idx: number } | null {
const idx = repositories.findIndex((r) => !r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getPrimaryPromotionRepository(): { repo: GitSyncRepository; idx: number } | null {
const idx = repositories.findIndex((r) => r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getSecondarySyncRepositories(): { repo: GitSyncRepository; idx: number }[] {
const result: { repo: GitSyncRepository; idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (!repo.use_individual_branch) {
if (foundFirst) {
result.push({ repo, idx })
} else {
foundFirst = true
}
}
})
return result
}
function getSecondaryPromotionRepositories(): { repo: GitSyncRepository; idx: number }[] {
const result: { repo: GitSyncRepository; idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (repo.use_individual_branch) {
if (foundFirst) {
result.push({ repo, idx })
} else {
foundFirst = true
}
}
})
return result
}
async function removeRepositoryByPath(resourcePath: string) {
const idx = repositories.findIndex((r) => r.git_repo_resource_path === resourcePath)
if (idx !== -1) {
await removeRepository(idx)
}
}
function addSyncRepository() {
if (!get(enterpriseLicense) && repositories && repositories.length >= 1) {
sendUserToast('Multiple repositories requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
use_individual_branch: false,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: [
'script',
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false
// Pull-from-Git defaults are applied once the selected resource resolves, by
// whoever is configuring the connection (see applyNewConnectionDefaults): only
// app-backed repos (instant webhook delivery) default to auto-pull on; polling is
// opt-in for token repos.
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
function addPromotionRepository() {
if (!get(enterpriseLicense)) {
sendUserToast('Promotion mode requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
use_individual_branch: true,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: [
'script',
'flow',
'app',
'folder',
'resource',
'resourcetype',
'variable',
'schedule',
'trigger',
'workspacedependencies',
'datatablemigration'
]
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false,
// New promotion repos default to opening the PR in-app when a deploy
// pushes its wm_deploy/** branch (app-backed repos only at runtime).
promotion_open_prs: true
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
// Helper to get target branch from git resource
async function getTargetBranch(repo: GitSyncRepository): Promise<string | undefined> {
if (!repo.git_repo_resource_path) {
return undefined
}
if (repo._targetBranch) {
if (repo._targetBranch === '') return undefined
return repo._targetBranch
}
try {
const resource = await ResourceService.getResource({
workspace,
path: repo.git_repo_resource_path
})
// Extract branch from git resource value
const resourceValue = resource.value as any
const targetBranch = resourceValue?.branch
// Cache the result
repo._targetBranch = targetBranch
if (targetBranch === '') return undefined
return targetBranch
} catch (error) {
console.warn('Failed to fetch git resource for branch info:', error)
return undefined
}
}
// Return context object
return {
// State (read-only access)
get repositories() {
return repositories
},
get loading() {
return loading
},
get activeModals() {
return activeModals
},
get gitSyncTestJobs() {
return gitSyncTestJobs
},
get initialRepositories() {
return initialRepositories
},
get legacyWorkspaceIncludePath() {
return legacyWorkspaceIncludePath
},
get legacyWorkspaceIncludeType() {
return legacyWorkspaceIncludeType
},
// Computed states - use getter functions that compute on access
get validationStates() {
return getValidationStates()
},
get hasAnyChanges() {
return getHasAnyChanges()
},
get allRepositoriesValid() {
return getAllRepositoriesValid()
},
get hasUnsavedConnections() {
return getHasUnsavedConnections()
},
get hasWorkspaceLevelSettings() {
return hasWorkspaceLevelSettings
},
// Methods
addRepository,
addSyncRepository,
addPromotionRepository,
removeRepository,
removeRepositoryByPath,
getRepository,
getValidation,
revertRepository,
runTestJob,
resetDetectionState,
detectRepository,
migrateLegacyRepository,
showPushModal,
showPullModal,
closePushModal,
closePullModal,
showSuccessModal,
closeSuccessModal,
loadSettings,
saveRepository,
// Repository categorization methods
getPrimarySyncRepository,
getPrimaryPromotionRepository,
getSecondarySyncRepositories,
getSecondaryPromotionRepositories,
// Helper methods
getTargetBranch
}
}
export type GitSyncContextType = ReturnType<typeof createGitSyncContext>
export function setGitSyncContext(workspace: string): GitSyncContextType {
const context = createGitSyncContext(workspace)
setContext(GIT_SYNC_CONTEXT_KEY, context)
return context
}
export function getGitSyncContext(): GitSyncContextType {
const context = getContext<GitSyncContextType>(GIT_SYNC_CONTEXT_KEY)
if (!context) {
throw new Error('Git sync context not found. Make sure to call setGitSyncContext first.')
}
return context
}