diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 2cde798d1e..fa29675d6c 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2fb7884849a563bae023574baa2d55fa1fab1176 +a797dd4d619cdab737e133ce593f2f8582ba21de diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d93953abda..0ffb19fbca 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -23237,6 +23237,8 @@ components: type: boolean group_by_folder: type: boolean + force_branch: + type: string collapsed: type: boolean settings: diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 8c4d94ef15..cc255875d5 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,6 +157,8 @@ pub struct GitRepositorySettings { pub use_individual_branch: Option, pub group_by_folder: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub force_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, } diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 33b299cba0..1f6e8fbfaa 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,6 +1,9 @@ import { getContext, setContext } from 'svelte' import { JobService, WorkspaceService, ResourceService } from '$lib/gen' -import type { GitRepositorySettings as BackendGitRepositorySettings, GitSyncObjectType } 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' @@ -37,9 +40,9 @@ export type GitSyncSettings = { } 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 } | null + push: { idx: number; repo: GitSyncRepository; open: boolean } | null + pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null + success: { open: boolean; savedWithoutInit?: boolean } | null } export type ValidationState = { @@ -101,10 +104,10 @@ export function createGitSyncContext(workspace: string) { const validationStates = getValidationStates() // Check if any individual repositories have changes - const individualChanges = validationStates.some(v => v.hasChanges) + const individualChanges = validationStates.some((v) => v.hasChanges) // Check if any legacy repos were imported - const anyLegacyImported = repositories.some(r => r.legacyImported) + const anyLegacyImported = repositories.some((r) => r.legacyImported) // Check if the set of repositories has changed (added/removed repos) const repositorySetChanged = (() => { @@ -113,31 +116,33 @@ export function createGitSyncContext(workspace: string) { } if (!initialRepositories || initialRepositories.length === 0) { - return repositories.filter((_,i) => validationStates[i]?.isValid).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) + .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) + .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 ( + 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) + 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 @@ -146,7 +151,9 @@ export function createGitSyncContext(workspace: string) { 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) + const firstIdx = repositories.findIndex( + (r) => r.git_repo_resource_path === repo.git_repo_resource_path + ) return firstIdx !== -1 && firstIdx < idx } @@ -157,7 +164,9 @@ export function createGitSyncContext(workspace: string) { // Legacy repositories always have "changes" because they need migration if (repo.legacyImported) return true - return JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial)) + return ( + JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial)) + ) } function serializeRepository(repo: GitSyncRepository) { @@ -166,8 +175,9 @@ export function createGitSyncContext(workspace: string) { script_path: repo.script_path, use_individual_branch: repo.use_individual_branch, group_by_folder: repo.group_by_folder, + force_branch: repo.force_branch, settings: repo.settings, - exclude_types_override: repo.exclude_types_override, + exclude_types_override: repo.exclude_types_override } } @@ -200,7 +210,7 @@ export function createGitSyncContext(workspace: string) { // 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 + (initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path ) // Only call backend API if repository exists in the saved state @@ -214,7 +224,7 @@ export function createGitSyncContext(workspace: string) { // Update initial state to remove the deleted repository const initialIdx = initialRepositories.findIndex( - initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path + (initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path ) if (initialIdx !== -1) { initialRepositories.splice(initialIdx, 1) @@ -309,45 +319,42 @@ export function createGitSyncContext(workspace: string) { repo._detectionTimestamp = detectionTimestamp // 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 - } + 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 + 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 || [] - } + // 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' } + } 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) { @@ -369,68 +376,88 @@ export function createGitSyncContext(workspace: string) { 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 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) + 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 ?? [] + 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'] + // Determine default types - use workspace legacy or fallback + const defaultTypes: GitSyncObjectType[] = + workspaceLegacyIncludeType.length > 0 + ? [...workspaceLegacyIncludeType] + : ['script', 'flow', 'app', 'folder'] - 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 + 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 + 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'] + } } - } 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'] - } - } - 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 - } - })) + 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 }))) + 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) { @@ -465,6 +492,7 @@ export function createGitSyncContext(workspace: string) { script_path: repoToSave.script_path, use_individual_branch: repoToSave.use_individual_branch, group_by_folder: repoToSave.group_by_folder, + force_branch: repoToSave.force_branch, settings: repoToSave.settings, exclude_types_override: repoToSave.exclude_types_override } @@ -485,8 +513,6 @@ export function createGitSyncContext(workspace: string) { } } - - // Helper functions for original functionality function revertRepository(idx: number) { @@ -496,8 +522,6 @@ export function createGitSyncContext(workspace: string) { } } - - // Reset detection state for a repository function resetDetectionState(idx: number) { const repo = repositories[idx] @@ -515,7 +539,6 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobStatus = undefined } - async function runTestJob(idx: number) { const repo = repositories[idx] if (!repo?.git_repo_resource_path || !repo?.script_path) { @@ -539,24 +562,29 @@ export function createGitSyncContext(workspace: string) { } // Use JobManager for polling - await jobManager.runWithProgress( - () => Promise.resolve(jobId), - { - workspace, - timeout: 5000, - timeoutMessage: 'Git sync test job timed out after 5s', - onProgress: (status) => { - gitSyncTestJobs[idx].status = status.status === 'success' ? 'success' : - status.status === 'failure' ? 'failure' : 'running' - if (status.status === 'failure') { - gitSyncTestJobs[idx].error = status.error - } + await jobManager.runWithProgress(() => Promise.resolve(jobId), { + workspace, + timeout: 5000, + timeoutMessage: 'Git sync test job timed out after 5s', + 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 = (typeof error?.body === 'string' ? error.body : error?.body?.message) || error?.message || error?.toString() || 'Failed to run test job' + const errorMessage = + (typeof error?.body === 'string' ? error.body : error?.body?.message) || + error?.message || + error?.toString() || + 'Failed to run test job' if (!gitSyncTestJobs[idx]) { gitSyncTestJobs[idx] = { jobId: '', @@ -570,18 +598,18 @@ export function createGitSyncContext(workspace: string) { } } - function getPrimarySyncRepository(): { repo: GitSyncRepository, idx: number } | null { - const idx = repositories.findIndex(r => !r.use_individual_branch) + 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) + 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 }[] = [] + function getSecondarySyncRepositories(): { repo: GitSyncRepository; idx: number }[] { + const result: { repo: GitSyncRepository; idx: number }[] = [] let foundFirst = false repositories.forEach((repo, idx) => { if (!repo.use_individual_branch) { @@ -595,8 +623,8 @@ export function createGitSyncContext(workspace: string) { return result } - function getSecondaryPromotionRepositories(): { repo: GitSyncRepository, idx: number }[] { - const result: { repo: GitSyncRepository, idx: number }[] = [] + function getSecondaryPromotionRepositories(): { repo: GitSyncRepository; idx: number }[] { + const result: { repo: GitSyncRepository; idx: number }[] = [] let foundFirst = false repositories.forEach((repo, idx) => { if (repo.use_individual_branch) { @@ -611,7 +639,7 @@ export function createGitSyncContext(workspace: string) { } async function removeRepositoryByPath(resourcePath: string) { - const idx = repositories.findIndex(r => r.git_repo_resource_path === resourcePath) + const idx = repositories.findIndex((r) => r.git_repo_resource_path === resourcePath) if (idx !== -1) { await removeRepository(idx) } @@ -697,20 +725,44 @@ export function createGitSyncContext(workspace: string) { // 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 }, + 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 }, + get validationStates() { + return getValidationStates() + }, + get hasAnyChanges() { + return getHasAnyChanges() + }, + get allRepositoriesValid() { + return getAllRepositoriesValid() + }, + get hasUnsavedConnections() { + return getHasUnsavedConnections() + }, + get hasWorkspaceLevelSettings() { + return hasWorkspaceLevelSettings + }, // Methods addRepository, @@ -741,7 +793,7 @@ export function createGitSyncContext(workspace: string) { getSecondaryPromotionRepositories, // Helper methods - getTargetBranch, + getTargetBranch } } diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 5d2c7e2b77..af6f361286 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -11,11 +11,14 @@ Plus } from 'lucide-svelte' import { Button, Alert } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Section from '$lib/components/Section.svelte' 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 Toggle from '$lib/components/Toggle.svelte' import { fade } from 'svelte/transition' import { workspaceStore } from '$lib/stores' import hubPaths from '$lib/hubPaths.json' @@ -527,6 +530,30 @@ {/if} + + +
+ { + if (e.detail) { + repo.force_branch = $workspaceStore ?? '' + } else { + repo.force_branch = undefined + } + }} + options={{ + right: 'Environment (experimental)', + rightTooltip: + 'Made for monobranch setups. Passes the value as --branch/--env to the wmill CLI, which selects the matching branch/env configuration from wmill.yaml and includes the branch/env in the item paths.' + }} + /> + {#if repo.force_branch != null && repo.force_branch !== undefined} +
+ +
+ {/if} +
{/if} {/if} {:else} diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 8cf984a8aa..79bf362003 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -21,7 +21,8 @@ "gitSync_19": "hub/28081/sync-script-to-git-repo-windmill", "gitSync_20": "hub/28102/sync-script-to-git-repo-windmill", "gitSync_21": "hub/28131/sync-script-to-git-repo-windmill", - "gitSync": "hub/28159/sync-script-to-git-repo-windmill", + "gitSync_22": "hub/28159/sync-script-to-git-repo-windmill", + "gitSync": "hub/28160/sync-script-to-git-repo-windmill", "gitSyncTest": "hub/19799/git-repo-test-read-write-windmill", "gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill", "gitInitRepo": "hub/28158/git-sync%3A-init-repository-windmill",